• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
You are now the owner of this repo.

Freegle / iznik-server / #2572

16 Jan 2026 09:57PM UTC coverage: 85.918%. Remained the same
#2572

push

edwh
fix: Fix getMods test type comparison and remove obsolete testPopular

- Cast user IDs to int in assertContains calls since getMods returns integers
  but createTestUser returns string IDs
- Remove testPopular test that references getPopularMessages() method that
  was removed in commit 409e7dac4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

25497 of 29676 relevant lines covered (85.92%)

30.37 hits per line

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

95.77
/include/user/User.php
1
<?php
2
namespace Freegle\Iznik;
3

4
require_once(IZNIK_BASE . '/mailtemplates/invite.php');
57✔
5
require_once(IZNIK_BASE . '/lib/wordle/functions.php');
57✔
6
require_once(IZNIK_BASE . '/lib/GreatCircle.php');
57✔
7

8
use Jenssegers\ImageHash\ImageHash;
9

10
class User extends Entity
11
{
12
    # We have a cache of users, because we create users a _lot_, and this can speed things up significantly by avoiding
13
    # hitting the DB.
14
    static $cache = [];
15
    static $cacheDeleted = [];
16
    const CACHE_SIZE = 100;
17

18
    const OPEN_AGE = 90;
19

20
    const KUDOS_NEWBIE = 'Newbie';
21
    const KUDOS_OCCASIONAL = 'Occasional';
22
    const KUDOS_FREQUENT = 'Frequent';
23
    const KUDOS_AVID = 'Avid';
24

25
    const RATING_UP = 'Up';
26
    const RATING_DOWN = 'Down';
27
    const RATING_MINE = 'Mine';
28
    const RATING_UNKNOWN = 'Unknown';
29

30
    const RATINGS_REASON_NOSHOW = 'NoShow';
31
    const RATINGS_REASON_PUNCTUALITY = 'Punctuality';
32
    const RATINGS_REASON_GHOSTED = 'Ghosted';
33
    const RATINGS_REASON_RUDE = 'Rude';
34
    const RATINGS_REASON_OTHER = 'Other';
35

36
    const TRUST_EXCLUDED = 'Excluded';
37
    const TRUST_DECLINED = 'Declined';
38
    const TRUST_BASIC = 'Basic';
39
    const TRUST_MODERATE = 'Moderate';
40
    const TRUST_ADVANCED = 'Advanced';
41

42
    /** @var  $dbhm LoggedPDO */
43
    var $publicatts = array('id', 'firstname', 'lastname', 'fullname', 'systemrole', 'settings', 'yahooid', 'newslettersallowed', 'relevantallowed', 'bouncing', 'added', 'invitesleft', 'onholidaytill', 'tnuserid', 'ljuserid', 'deleted', 'forgotten');
44

45
    # Roles on specific groups
46
    const ROLE_NONMEMBER = 'Non-member';
47
    const ROLE_MEMBER = 'Member';
48
    const ROLE_MODERATOR = 'Moderator';
49
    const ROLE_OWNER = 'Owner';
50

51
    # Permissions
52
    const PERM_BUSINESS_CARDS = 'BusinessCardsAdmin';
53
    const PERM_NEWSLETTER = 'Newsletter';
54
    const PERM_NATIONAL_VOLUNTEERS = 'NationalVolunteers';
55
    const PERM_TEAMS = 'Teams';
56
    const PERM_GIFTAID = 'GiftAid';
57
    const PERM_SPAM_ADMIN = 'SpamAdmin';
58

59
    const HAPPY = 'Happy';
60
    const FINE = 'Fine';
61
    const UNHAPPY = 'Unhappy';
62

63
    # Role on site
64
    const SYSTEMROLE_SUPPORT = 'Support';
65
    const SYSTEMROLE_ADMIN = 'Admin';
66
    const SYSTEMROLE_USER = 'User';
67
    const SYSTEMROLE_MODERATOR = 'Moderator';
68

69
    const LOGIN_YAHOO = 'Yahoo';
70
    const LOGIN_FACEBOOK = 'Facebook';
71
    const LOGIN_GOOGLE = 'Google';
72
    const LOGIN_NATIVE = 'Native';
73
    const LOGIN_LINK = 'Link';
74

75
    const NOTIFS_EMAIL = 'email';
76
    const NOTIFS_EMAIL_MINE = 'emailmine';
77
    const NOTIFS_PUSH = 'push';
78

79
    const INVITE_PENDING = 'Pending';
80
    const INVITE_ACCEPTED = 'Accepted';
81
    const INVITE_DECLINED = 'Declined';
82

83
    # Traffic sources
84
    const SRC_DIGEST = 'digest';
85
    const SRC_RELEVANT = 'relevant';
86
    const SRC_CHASEUP = 'chaseup';
87
    const SRC_CHASEUP_IDLE = 'beenawhile';
88
    const SRC_CHATNOTIF = 'chatnotif';
89
    const SRC_REPOST_WARNING = 'repostwarn';
90
    const SRC_FORGOT_PASSWORD = 'forgotpass';
91
    const SRC_PUSHNOTIF = 'pushnotif'; // From JS
92
    const SRC_EVENT_DIGEST = 'eventdigest';
93
    const SRC_VOLUNTEERING_DIGEST = 'voldigest';
94
    const SRC_VOLUNTEERING_RENEWAL = 'volrenew';
95
    const SRC_NEWSLETTER = 'newsletter';
96
    const SRC_NOTIFICATIONS_EMAIL = 'notifemail';
97
    const SRC_NEWSFEED_DIGEST = 'newsfeeddigest';
98
    const SRC_NOTICEBOARD = 'noticeboard';
99
    const SRC_ADMIN = 'admin';
100
    const SRC_DUMMY = 'dummy';
101
    const SRC_FBL = 'fbl';
102
    const SRC_CALENDAR = 'calendar';
103

104
    # Chat mod status
105
    const CHAT_MODSTATUS_MODERATED = 'Moderated';
106
    const CHAT_MODSTATUS_UNMODERATED = 'Unmoderated';
107
    const CHAT_MODSTATUS_FULLY = 'Fully';
108

109
    # Newsfeed mod status
110
    const NEWSFEED_UNMODERATED = 'Unmoderated';
111
    const NEWSFEED_MODERATED = 'Moderated';
112
    const NEWSFEED_SUPPRESSED = 'Suppressed';
113

114
    # Simple email settings.
115
    const SIMPLE_MAIL_NONE = 'None';
116
    const SIMPLE_MAIL_BASIC = 'Basic';
117
    const SIMPLE_MAIL_FULL = 'Full';
118

119
    /** @var  $log Log */
120
    private $log;
121
    var $user;
122
    private $memberships = NULL;
123
    private $ouremailid = NULL;
124
    private $emails = NULL;
125
    private $emailsord = NULL;
126
    private $profile = NULL;
127
    private $spammer = NULL;
128

129
    function __construct(LoggedPDO $dbhr, LoggedPDO $dbhm, $id = NULL)
130
    {
131
        #error_log("Construct user " .  debug_backtrace()[1]['function'] . "," . debug_backtrace()[2]['function']);
132
        # We don't use Entity::fetch because we can reduce the number of DB ops in getPublic later by
133
        # doing a more complex query here.  This adds code complexity, but as you can imagine the performance of
134
        # this class is critical.
135
        $this->log = new Log($dbhr, $dbhm);
626✔
136
        $this->notif = new PushNotifications($dbhr, $dbhm);
626✔
137
        $this->dbhr = $dbhr;
626✔
138
        $this->dbhm = $dbhm;
626✔
139
        $this->name = 'user';
626✔
140
        $this->user = NULL;
626✔
141
        $this->id = NULL;
626✔
142
        $this->table = 'users';
626✔
143
        $this->spammer = [];
626✔
144

145
        if ($id) {
626✔
146
            # Fetch the user.  There are so many users that there is no point trying to use the query cache.
147
            $sql = "SELECT * FROM users WHERE id = ?;";
587✔
148

149
            $users = $dbhr->preQuery($sql, [
587✔
150
                $id
587✔
151
            ]);
587✔
152

153
            foreach ($users as $user) {
587✔
154
                $this->user = $user;
587✔
155
                $this->id = $id;
587✔
156
            }
157
        }
158
    }
159

160
    public static function get(LoggedPDO $dbhr, LoggedPDO $dbhm, $id = NULL, $usecache = TRUE, $testonly = FALSE)
161
    {
162
        $u = NULL;
618✔
163

164
        if ($id) {
618✔
165
            # We cache the constructed user.
166
            if ($usecache && array_key_exists($id, User::$cache) && User::$cache[$id]->getId() == $id) {
586✔
167
                # We found it.
168
                # @var User
169
                $u = User::$cache[$id];
551✔
170
                #error_log("Found $id in cache with " . $u->getId());
171

172
                if (!User::$cacheDeleted[$id]) {
551✔
173
                    # And it's not zapped - so we can use it.
174
                    #error_log("Not zapped");
175
                    return ($u);
544✔
176
                } else if (!$testonly) {
424✔
177
                    # It's zapped - so refetch.  It's important that we do this using the original DB handles, because
178
                    # whatever caused us to zap the cache might have done a modification operation which in turn
179
                    # zapped the SQL read cache.
180
                    #error_log("Zapped, refetch " . $id);
181
                    $u->fetch($u->dbhr, $u->dbhm, $id, 'users', 'user', $u->publicatts);
423✔
182
                    #error_log("Fetched $id as " . $u->getId() . " mod " . $u->isModerator());
183

184
                    User::$cache[$id] = $u;
423✔
185
                    User::$cacheDeleted[$id] = FALSE;
423✔
186
                    return ($u);
423✔
187
                }
188
            }
189
        }
190

191
        # Not cached.
192
        if (!$testonly) {
618✔
193
            #error_log("$id not in cache");
194
            $u = new User($dbhr, $dbhm, $id);
618✔
195

196
            if ($id && count(User::$cache) < User::CACHE_SIZE) {
618✔
197
                # Store for next time
198
                #error_log("store $id in cache");
199
                User::$cache[$id] = $u;
586✔
200
                User::$cacheDeleted[$id] = FALSE;
586✔
201
            }
202
        }
203

204
        return ($u);
618✔
205
    }
206

207
    public static function clearCache($id = NULL)
208
    {
209
        # Remove this user from our cache.
210
        #error_log("Clear $id from cache");
211
        if ($id) {
914✔
212
            User::$cacheDeleted[$id] = TRUE;
539✔
213
        } else {
214
            User::$cache = [];
914✔
215
            User::$cacheDeleted = [];
914✔
216
        }
217
    }
218

219
    public function hashPassword($pw, $salt = PASSWORD_SALT)
220
    {
221
        return sha1($pw . $salt);
513✔
222
    }
223

224
    public function login($suppliedpw, $force = FALSE)
225
    {
226
        # TODO lockout
227
        if ($this->id) {
292✔
228
            $logins = $this->getLogins(TRUE);
292✔
229

230
            foreach ($logins as $login) {
292✔
231
                if ($login['type'] == User::LOGIN_NATIVE) {
292✔
232
                    $pw = $this->hashPassword($suppliedpw, Utils::presdef('salt', $login, PASSWORD_SALT));
292✔
233

234
                    if ($force || ($login['type'] == User::LOGIN_NATIVE && $login['uid'] == $this->id && strtolower(
292✔
235
                                $pw
292✔
236
                            ) == strtolower($login['credentials']))) {
292✔
237
                        $s = new Session($this->dbhr, $this->dbhm);
292✔
238
                        $s->create($this->id);
292✔
239

240
                        User::clearCache($this->id);
292✔
241

242
                        $l = new Log($this->dbhr, $this->dbhm);
292✔
243
                        $l->log([
292✔
244
                                    'type' => Log::TYPE_USER,
292✔
245
                                    'subtype' => Log::SUBTYPE_LOGIN,
292✔
246
                                    'byuser' => $this->id,
292✔
247
                                    'text' => 'Using email/password'
292✔
248
                                ]);
292✔
249

250
                        return (TRUE);
292✔
251
                    }
252
                }
253
            }
254
        }
255

256
        return (FALSE);
4✔
257
    }
258

259
    public function linkLogin($key)
260
    {
261
        $ret = TRUE;
2✔
262

263
        if (Utils::presdef('id', $_SESSION, NULL) != $this->id) {
2✔
264
            # We're not already logged in as this user.
265
            $ret = FALSE;
2✔
266

267
            $sql = "SELECT users_logins.* FROM users_logins INNER JOIN users ON users.id = users_logins.userid WHERE userid = ? AND type = ? AND credentials = ?;";
2✔
268

269
            $logins = $this->dbhr->preQuery($sql, [$this->id, User::LOGIN_LINK, $key]);
2✔
270
            foreach ($logins as $login) {
2✔
271
                # We found a match - log them in.
272
                $s = new Session($this->dbhr, $this->dbhm);
2✔
273
                $s->create($this->id);
2✔
274

275
                $l = new Log($this->dbhr, $this->dbhm);
2✔
276
                $l->log([
2✔
277
                    'type' => Log::TYPE_USER,
2✔
278
                    'subtype' => Log::SUBTYPE_LOGIN,
2✔
279
                    'byuser' => $this->id,
2✔
280
                    'text' => 'Using link'
2✔
281
                ]);
2✔
282

283
                $this->dbhm->preExec("UPDATE users_logins SET lastaccess = NOW() WHERE userid = ? AND type = ?;", [
2✔
284
                    $this->id,
2✔
285
                    User::LOGIN_LINK
2✔
286
                ]);
2✔
287

288
                $ret = TRUE;
2✔
289
            }
290
        }
291

292
        return ($ret);
2✔
293
    }
294

295
    public function getBounce()
296
    {
297
        return ("bounce-{$this->id}-" . time() . "@" . USER_DOMAIN);
34✔
298
    }
299

300
    public function getName($default = TRUE, $atts = NULL)
301
    {
302
        $atts = $atts ? $atts : $this->user;
587✔
303

304
        $name = NULL;
587✔
305

306
        # We may or may not have the knowledge about how the name is split out, depending
307
        # on the sign-in mechanism.
308
        if (Utils::pres('fullname', $atts)) {
587✔
309
            $name = $atts['fullname'];
485✔
310
        } else if (Utils::pres('firstname', $atts) || Utils::pres('lastname', $atts)) {
128✔
311
            $first = Utils::pres('firstname', $atts);
122✔
312
            $last = Utils::pres('lastname', $atts);
122✔
313

314
            $name = $first && $last ? "$first $last" : ($first ? $first : $last);
122✔
315
        }
316

317
        # Make sure we don't return an email if somehow one has snuck in.
318
        $name = ($name && strpos($name, '@') !== FALSE) ? substr($name, 0, strpos($name, '@')) : $name;
587✔
319

320
        # If we are logged in as this user and it's showing deleted then we've resurrected it; give it a new name.
321
        $resurrect = isset($_SESSION) && Utils::presdef('id', $_SESSION, NULL) == $this->id && strpos($name, 'Deleted User') === 0;
587✔
322

323
        if ($default &&
587✔
324
            $this->id &&
587✔
325
            (strlen(trim($name)) === 0 ||
587✔
326
                $name == 'A freegler' ||
587✔
327
                $resurrect ||
587✔
328
                (strlen($name) == 32 && preg_match('/[A-Za-z].*[0-9]|[0-9].*[A-Za-z]/', $name)) ||
587✔
329
                strpos($name, 'FBUser') !== FALSE)
587✔
330
        ) {
331
            # We have:
332
            # - no name, or
333
            # - a name derived from a Yahoo ID which is a hex string, which looks silly
334
            # - A freegler, which was an old way of anonymising.
335
            # - A very old FBUser name
336
            $u = new User($this->dbhr, $this->dbhm, $atts['id']);
17✔
337
            $email = $u->inventEmail();
17✔
338
            $name = substr($email, 0, strpos($email, '-'));
17✔
339
            $u->setPrivate('fullname', $name);
17✔
340
            $u->setPrivate('inventedname', 1);
17✔
341
        }
342

343
        # Stop silly long names.
344
        $name = strlen($name) > 32 ? (substr($name, 0, 32) . '...') : $name;
587✔
345

346
        # Numeric names confuse the client.
347
        $name = is_numeric($name) ? "$name." : $name;
587✔
348

349
        # TN group numbers in names confuse everyone.
350
        $name = User::removeTNGroup($name);
587✔
351

352
        return ($name);
587✔
353
    }
354

355
    public static function removeTNGroup($name) {
356
        # For users, we hide the "-gxxx" part of names, which will almost always be for TN members.
357
        return preg_replace('/^([\s\S]+?)-g[0-9]+$/', '$1', $name);
594✔
358
    }
359

360
    /**
361
     * @param LoggedPDO $dbhm
362
     */
363
    public function setDbhm($dbhm)
364
    {
365
        $this->dbhm = $dbhm;
2✔
366
    }
367

368
    public function create($firstname, $lastname, $fullname, $reason = '', $yahooid = NULL)
369
    {
370
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
587✔
371

372
        try {
373
            $src = Utils::presdef('src', $_SESSION, NULL);
587✔
374
            $rc = $this->dbhm->preExec("INSERT INTO users (firstname, lastname, fullname, yahooid, source) VALUES (?, ?, ?, ?, ?)",
587✔
375
                [$firstname, $lastname, $fullname, $yahooid, $src]);
587✔
376
            $id = $this->dbhm->lastInsertId();
586✔
377
        } catch (\Exception $e) {
1✔
378
            $id = NULL;
1✔
379
            $rc = 0;
1✔
380
        }
381

382
        if ($rc && $id) {
587✔
383
            $this->fetch($this->dbhm, $this->dbhm, $id, 'users', 'user', $this->publicatts);
586✔
384
            $this->log->log([
586✔
385
                'type' => Log::TYPE_USER,
586✔
386
                'subtype' => Log::SUBTYPE_CREATED,
586✔
387
                'user' => $id,
586✔
388
                'byuser' => $me ? $me->getId() : NULL,
586✔
389
                'text' => $this->getName() . " #$id " . $reason
586✔
390
            ]);
586✔
391

392
            # Encourage them to introduce themselves.
393
            $n = new Notifications($this->dbhr, $this->dbhm);
586✔
394
            $n->add(NULL, $id, Notifications::TYPE_ABOUT_ME, NULL, NULL, NULL);
586✔
395

396
            return ($id);
586✔
397
        } else {
398
            return (NULL);
1✔
399
        }
400
    }
401

402
    public function inventPassword()
403
    {
404
        $spamwords = [];
5✔
405
        $ws = $this->dbhr->preQuery("SELECT word FROM spam_keywords");
5✔
406
        foreach ($ws as $w) {
5✔
407
            $spamwords[] = strtolower($w['word']);
5✔
408
        }
409

410
        $lengths = json_decode(file_get_contents(IZNIK_BASE . '/lib/wordle/data/distinct_word_lengths.json'), TRUE);
5✔
411
        $bigrams = json_decode(file_get_contents(IZNIK_BASE . '/lib/wordle/data/word_start_bigrams.json'), TRUE);
5✔
412
        $trigrams = json_decode(file_get_contents(IZNIK_BASE . '/lib/wordle/data/trigrams.json'), TRUE);
5✔
413

414
        $pw = '';
5✔
415

416
        do {
417
            $length = \Wordle\array_weighted_rand($lengths);
5✔
418
            $start = \Wordle\array_weighted_rand($bigrams);
5✔
419
            $word = \Wordle\fill_word($start, $length, $trigrams);
5✔
420

421
            if (!in_array(strtolower($word), $spamwords)) {
5✔
422
                $pw .= $word;
5✔
423
            }
424
        } while (strlen($pw) < 6);
5✔
425

426
        $pw = strtolower($pw);
5✔
427
        return ($pw);
5✔
428
    }
429

430
    public function getEmails($recent = FALSE, $nobouncing = FALSE)
431
    {
432
        #error_log("Get emails " .  debug_backtrace()[1]['function']);
433
        # Don't return canon - don't need it on the client.
434
        $ordq = $recent ? 'id' : 'preferred';
366✔
435

436
        if (!$this->emails || $ordq != $this->emailsord) {
366✔
437
            $bounceq = $nobouncing ? " AND bounced IS NULL " : '';
366✔
438
            $sql = "SELECT id, userid, email, preferred, added, validated FROM users_emails WHERE userid = ? $bounceq ORDER BY $ordq DESC, email ASC;";
366✔
439
            #error_log("$sql, {$this->id}");
440
            $this->emails = $this->dbhr->preQuery($sql, [$this->id]);
366✔
441
            $this->emailsord = $ordq;
366✔
442

443
            foreach ($this->emails as &$email) {
366✔
444
                $email['ourdomain'] = Mail::ourDomain($email['email']);
322✔
445
            }
446
        }
447

448
        return ($this->emails);
366✔
449
    }
450

451
    public function getEmailsById($uids) {
452
        $ret = [];
177✔
453

454
        if ($uids && count($uids)) {
177✔
455
            $sql = "SELECT id, userid, email, preferred, added, validated FROM users_emails WHERE userid IN (" . implode(',', $uids) . ") ORDER BY preferred DESC, email ASC;";
175✔
456
            $emails = $this->dbhr->preQuery($sql, NULL, FALSE, FALSE);
175✔
457

458
            foreach ($emails as $email) {
175✔
459
                $email['ourdomain'] = Mail::ourDomain($email['email']);
159✔
460
                $ret[$email['userid']][] = $email;
159✔
461
            }
462
        }
463

464
        return ($ret);
177✔
465
    }
466

467
    public function getEmailPreferred($allowOurDomain = FALSE)
468
    {
469
        # This gets the email address which we think the user actually uses.  So we pay attention to:
470
        # - the preferred flag, which gets set by end user action
471
        # - the date added, as most recently added emails are most likely to be right
472
        # - exclude our own invented mails
473
        # - exclude any yahoo groups mails which have snuck in.
474
        $emails = $this->getEmails();
353✔
475
        $ret = NULL;
353✔
476

477
        foreach ($emails as $email) {
353✔
478
            if (($allowOurDomain || !Mail::ourDomain($email['email'])) &&
302✔
479
                strpos($email['email'], '@yahoogroups.') === FALSE &&
302✔
480
                strpos($email['email'], GROUP_DOMAIN) === FALSE) {
302✔
481
                $ret = $email['email'];
283✔
482
                break;
283✔
483
            }
484
        }
485

486
        return ($ret);
353✔
487
    }
488

489
    public function getOurEmail($emails = NULL)
490
    {
491
        $emails = $emails ? $emails : $this->dbhr->preQuery("SELECT id, userid, email, preferred, added, validated FROM users_emails WHERE userid = ? ORDER BY preferred DESC, added DESC;",
2✔
492
            [$this->id]);
2✔
493
        $ret = NULL;
2✔
494

495
        foreach ($emails as $email) {
2✔
496
            if (Mail::ourDomain($email['email'])) {
2✔
497
                $ret = $email['email'];
1✔
498
                break;
1✔
499
            }
500
        }
501

502
        return ($ret);
2✔
503
    }
504

505
    public function getAnEmailId()
506
    {
507
        $emails = $this->dbhr->preQuery("SELECT id FROM users_emails WHERE userid = ? ORDER BY preferred DESC;",
9✔
508
            [$this->id]);
9✔
509
        return (count($emails) == 0 ? NULL : $emails[0]['id']);
9✔
510
    }
511

512
    public function isApprovedMember($groupid)
513
    {
514
        $membs = $this->dbhr->preQuery("SELECT id FROM memberships WHERE userid = ? AND groupid = ? AND collection = 'Approved';", [$this->id, $groupid]);
327✔
515
        return (count($membs) > 0 ? $membs[0]['id'] : NULL);
327✔
516
    }
517

518
    public function getEmailAge($email)
519
    {
520
        $emails = $this->dbhr->preQuery("SELECT TIMESTAMPDIFF(HOUR, added, NOW()) AS ago FROM users_emails WHERE email LIKE ?;", [
1✔
521
            $email
1✔
522
        ]);
1✔
523

524
        return (count($emails) > 0 ? $emails[0]['ago'] : NULL);
1✔
525
    }
526

527
    public function getIdForEmail($email)
528
    {
529
        # Email is a unique key but conceivably we could be called with an email for another user.
530
        $ids = $this->dbhr->preQuery("SELECT id, userid FROM users_emails WHERE (email = ? OR canon = ?);", [
23✔
531
            $email,
23✔
532
            User::canonMail($email),
23✔
533
        ]);
23✔
534

535
        foreach ($ids as $id) {
23✔
536
            return ($id);
23✔
537
        }
538

539
        return (NULL);
1✔
540
    }
541

542
    public function getEmailById($id)
543
    {
544
        $emails = $this->dbhr->preQuery("SELECT email FROM users_emails WHERE id = ?;", [
1✔
545
            $id
1✔
546
        ]);
1✔
547

548
        $ret = NULL;
1✔
549

550
        foreach ($emails as $email) {
1✔
551
            $ret = $email['email'];
1✔
552
        }
553

554
        return ($ret);
1✔
555
    }
556

557
    public function findByTNId($id) {
558
        $ret = NULL;
4✔
559

560
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE tnuserid = ?;", [
4✔
561
            $id
4✔
562
        ]);
4✔
563

564
        foreach ($users as $user) {
4✔
565
            $ret = $user['id'];
×
566
        }
567

568
        return $ret;
4✔
569
    }
570

571
    public function findByEmail($email) {
572
        return $this->findByEmailIncludingUnvalidated($email)[0];
252✔
573
    }
574

575
    public function findByEmailIncludingUnvalidated($email)
576
    {
577
        if (preg_match('/.*\-(.*)\@' . USER_DOMAIN . '/', $email, $matches)) {
276✔
578
            # Our own email addresses have the UID in there.  This will match even if the email address has
579
            # somehow been removed from the list.
580
            $uid = $matches[1];
33✔
581
            $users = $this->dbhr->preQuery("SELECT id FROM users WHERE id = ?;", [
33✔
582
                $uid
33✔
583
            ]);
33✔
584

585
            foreach ($users as $user) {
33✔
586
                return [ $user['id'], FALSE ];
4✔
587
            }
588
        }
589

590
        # Take care not to pick up empty or null else that will cause is to overmerge.
591
        #
592
        # Use canon to match - that handles variant TN addresses or % addressing.
593
        $users = $this->dbhr->preQuery("SELECT id, userid FROM users_emails WHERE (email = ? OR canon = ?) AND canon IS NOT NULL AND LENGTH(canon) > 0;",
276✔
594
            [
276✔
595
                $email,
276✔
596
                User::canonMail($email),
276✔
597
            ]);
276✔
598

599
        foreach ($users as $user) {
276✔
600
            if ($user['userid']) {
251✔
601
                return [ $user['userid'], FALSE ];
250✔
602
            } else {
603
                // This email is not yet validated.
604
                return [ NULL, TRUE ];
1✔
605
            }
606
        }
607

608
        return [ NULL, FALSE ];
132✔
609
    }
610

611
    public function findByEmailHash($hash)
612
    {
613
        # Take care not to pick up empty or null else that will cause is to overmerge.
614
        $users = $this->dbhr->preQuery("SELECT userid FROM users_emails WHERE md5hash LIKE ? AND md5hash IS NOT NULL AND LENGTH(md5hash) > 0;",
1✔
615
            [
1✔
616
                User::canonMail($hash),
1✔
617
            ]);
1✔
618

619
        foreach ($users as $user) {
1✔
620
            return ($user['userid']);
1✔
621
        }
622

623
        return (NULL);
1✔
624
    }
625

626
    public function findByYahooId($id)
627
    {
628
        # Take care not to pick up empty or null else that will cause is to overmerge.
629
        $users = $this->dbhr->preQuery("SELECT id FROM users WHERE yahooid = ? AND yahooid IS NOT NULL AND LENGTH(yahooid) > 0;",
8✔
630
            [$id]);
8✔
631

632
        foreach ($users as $user) {
8✔
633
            return ($user['id']);
6✔
634
        }
635

636
        return (NULL);
3✔
637
    }
638

639
    public static function canonMail($email)
640
    {
641
        # Googlemail is Gmail really in US and UK.
642
        $email = str_replace('@googlemail.', '@gmail.', $email);
517✔
643
        $email = str_replace('@googlemail.co.uk', '@gmail.co.uk', $email);
517✔
644

645
        # Canonicalise TN addresses.
646
        if (preg_match('/(.*)\-(.*)(@user.trashnothing.com)/', $email, $matches)) {
517✔
647
            $email = $matches[1] . $matches[3];
4✔
648
        }
649

650
        # Remove plus addressing, which is sometimes used by spammers as a trick, except for Facebook where it
651
        # appears to be genuinely used for routing to distinct users.
652
        #
653
        # O2 puts a + at the start of an email address.  That would lead to us canonicalising all emails the same.
654
        if (substr($email, 0, 1) != '+' && preg_match('/(.*)\+(.*)(@.*)/', $email, $matches) && strpos($email, '@proxymail.facebook.com') === FALSE) {
517✔
655
            $email = $matches[1] . $matches[3];
1✔
656
        }
657

658
        # Remove dots in LHS, which are ignored by gmail and can therefore be used to give the appearance of separate
659
        # emails.
660
        $p = strpos($email, '@');
517✔
661

662
        if ($p !== FALSE) {
517✔
663
            $lhs = substr($email, 0, $p);
517✔
664
            $rhs = substr($email, $p);
517✔
665

666
            if (stripos($rhs, '@gmail') !== FALSE || stripos($rhs, '@googlemail') !== FALSE) {
517✔
667
                $lhs = str_replace('.', '', $lhs);
4✔
668
            }
669

670
            # Remove dots from the RHS - saves a little space and is the format we have historically used.
671
            # Very unlikely to introduce ambiguity.
672
            $email = $lhs . str_replace('.', '', $rhs);
517✔
673
        }
674

675
        return ($email);
517✔
676
    }
677

678
    public function addEmail($email, $primary = 1, $changeprimary = TRUE)
679
    {
680
        $email = trim($email);
518✔
681

682
        # Invalidate cache.
683
        $this->emails = NULL;
518✔
684

685
        if (stripos($email, '-owner@yahoogroups.co') !== FALSE ||
518✔
686
            stripos($email, '-volunteers@' . GROUP_DOMAIN) !== FALSE ||
518✔
687
            stripos($email, '-auto@' . GROUP_DOMAIN) !== FALSE)
518✔
688
        {
689
            # We don't allow people to add Yahoo owner addresses as the address of an individual user, or
690
            # the volunteer addresses.
691
            $rc = NULL;
1✔
692
        } else if (stripos($email, 'replyto-') !== FALSE || stripos($email, 'notify-') !== FALSE) {
517✔
693
            # This can happen because of dodgy email clients replying to the wrong place.  We don't want to end up
694
            # with this getting added to the user.
695
            $rc = NULL;
1✔
696
        } else {
697
            # If the email already exists in the table, then that's fine.  But we don't want to use INSERT IGNORE as
698
            # that scales badly for clusters.
699
            $canon = User::canonMail($email);
516✔
700

701
            # Don't cache - lots of emails so don't want to flood the query cache.
702
            $sql = "SELECT SQL_NO_CACHE id, preferred FROM users_emails WHERE userid = ? AND email = ?;";
516✔
703
            $emails = $this->dbhm->preQuery($sql, [
516✔
704
                $this->id,
516✔
705
                $email
516✔
706
            ]);
516✔
707

708
            if (count($emails) == 0) {
516✔
709
                $sql = "INSERT IGNORE INTO users_emails (userid, email, preferred, canon, backwards) VALUES (?, ?, ?, ?, ?)";
516✔
710
                $rc = $this->dbhm->preExec($sql,
516✔
711
                    [$this->id, $email, $primary, $canon, strrev($canon)]);
516✔
712
                $rc = $this->dbhm->lastInsertId();
516✔
713

714
                if ($rc && $primary) {
516✔
715
                    # Make sure no other email is flagged as primary
716
                    $this->dbhm->preExec("UPDATE users_emails SET preferred = 0 WHERE userid = ? AND id != ?;", [
516✔
717
                        $this->id,
516✔
718
                        $rc
516✔
719
                    ]);
516✔
720
                }
721
            } else {
722
                $rc = $emails[0]['id'];
29✔
723

724
                if ($changeprimary && $primary != $emails[0]['preferred']) {
29✔
725
                    # Change in status.
726
                    $this->dbhm->preExec("UPDATE users_emails SET preferred = ? WHERE id = ?;", [
2✔
727
                        $primary,
2✔
728
                        $rc
2✔
729
                    ]);
2✔
730
                }
731

732
                if ($primary) {
29✔
733
                    # Make sure no other email is flagged as primary
734
                    $this->dbhm->preExec("UPDATE users_emails SET preferred = 0 WHERE userid = ? AND id != ?;", [
27✔
735
                        $this->id,
27✔
736
                        $rc
27✔
737
                    ]);
27✔
738

739
                    # If we've set an email we might no longer be bouncing.
740
                    $this->unbounce($rc, FALSE);
27✔
741
                }
742
            }
743
        }
744

745
        $this->assignUserToToDonation($email, $this->id);
518✔
746

747
        return ($rc);
518✔
748
    }
749

750
    public function unbounce($emailid, $log)
751
    {
752
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
29✔
753
        $myid = $me ? $me->getId() : NULL;
29✔
754

755
        if ($log) {
29✔
756
            $l = new Log($this->dbhr, $this->dbhm);
2✔
757

758
            $l->log([
2✔
759
                'type' => Log::TYPE_USER,
2✔
760
                'subtype' => Log::SUBTYPE_UNBOUNCE,
2✔
761
                'user' => $this->id,
2✔
762
                'byuser' => $myid
2✔
763
            ]);
2✔
764
        }
765

766
        if ($emailid) {
29✔
767
            $this->dbhm->preExec("UPDATE bounces_emails SET reset = 1 WHERE emailid = ?;", [$emailid]);
29✔
768
        }
769

770
        $this->dbhm->preExec("UPDATE users SET bouncing = 0 WHERE id = ?;", [$this->id]);
29✔
771
    }
772

773
    public function removeEmail($email)
774
    {
775
        # Invalidate cache.
776
        $this->emails = NULL;
15✔
777

778
        $rc = $this->dbhm->preExec("DELETE FROM users_emails WHERE userid = ? AND email = ?;",
15✔
779
            [$this->id, $email]);
15✔
780
        return ($rc);
15✔
781
    }
782

783
    private function updateSystemRole($role)
784
    {
785
        #error_log("Update systemrole $role on {$this->id}");
786
        User::clearCache($this->id);
457✔
787

788
        if ($role == User::ROLE_MODERATOR || $role == User::ROLE_OWNER) {
457✔
789
            $sql = "UPDATE users SET systemrole = ? WHERE id = ? AND systemrole = ?;";
155✔
790
            $this->dbhm->preExec($sql, [User::SYSTEMROLE_MODERATOR, $this->id, User::SYSTEMROLE_USER]);
155✔
791
            $this->user['systemrole'] = $this->user['systemrole'] == User::SYSTEMROLE_USER ?
155✔
792
                User::SYSTEMROLE_MODERATOR : $this->user['systemrole'];
155✔
793
        } else if ($this->user['systemrole'] == User::SYSTEMROLE_MODERATOR) {
443✔
794
            # Check that we are still a mod on a group, otherwise we need to demote ourselves.
795
            $sql = "SELECT id FROM memberships WHERE userid = ? AND role IN (?,?);";
29✔
796
            $roles = $this->dbhr->preQuery($sql, [
29✔
797
                $this->id,
29✔
798
                User::ROLE_MODERATOR,
29✔
799
                User::ROLE_OWNER
29✔
800
            ]);
29✔
801

802
            if (count($roles) == 0) {
29✔
803
                $sql = "UPDATE users SET systemrole = ? WHERE id = ?;";
24✔
804
                $this->dbhm->preExec($sql, [User::SYSTEMROLE_USER, $this->id]);
24✔
805
                $this->user['systemrole'] = User::SYSTEMROLE_USER;
24✔
806
            }
807
        }
808
    }
809

810
    public function postToCollection($groupid)
811
    {
812
        # Which collection should we post to?  If this is a group on Yahoo then ourPostingStatus will be NULL.  We
813
        # will post to Pending, and send the message to Yahoo; if the user is unmoderated on there it will come back
814
        # to us and move to Approved.  If there is a value for ourPostingStatus, then this is a native group and
815
        # we will use that.
816
        #
817
        # We shouldn't come through here with prohibited, but if we do, best send to pending.
818
        $ps = $this->getMembershipAtt($groupid, 'ourPostingStatus');
18✔
819
        $coll = (!$ps || $ps == Group::POSTING_MODERATED || $ps == Group::POSTING_PROHIBITED) ? MessageCollection::PENDING : MessageCollection::APPROVED;
18✔
820
        return ($coll);
18✔
821
    }
822

823
    public function isBanned($groupid) {
824
        $sql = "SELECT * FROM users_banned WHERE userid = ? AND groupid = ?;";
457✔
825
        $banneds = $this->dbhr->preQuery($sql, [
457✔
826
            $this->id,
457✔
827
            $groupid
457✔
828
        ]);
457✔
829

830
        foreach ($banneds as $banned) {
457✔
831
            return TRUE;
5✔
832
        }
833

834
        return FALSE;
457✔
835
    }
836

837
    public function addMembership($groupid, $role = User::ROLE_MEMBER, $emailid = NULL, $collection = MembershipCollection::APPROVED, $byemail = NULL, $addedhere = TRUE, $manual = NULL, $g = NULL)
838
    {
839
        $this->memberships = NULL;
457✔
840
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
457✔
841
        $g = $g ? $g : Group::get($this->dbhr, $this->dbhm, $groupid);
457✔
842

843
        Session::clearSessionCache();
457✔
844

845
        # Check if we're banned
846
        if ($this->isBanned($groupid)) {
457✔
847
            return FALSE;
3✔
848
        }
849

850
        $existing = $this->dbhm->preQuery("SELECT COUNT(*) AS count FROM memberships WHERE userid = ? AND groupid = ? AND collection = ?;", [
457✔
851
            $this->id,
457✔
852
            $groupid,
457✔
853
            $collection
457✔
854
        ]);
457✔
855

856
        # We don't want to use REPLACE INTO because the membershipid is a foreign key in some tables, and if the
857
        # membership already exists, then this would cause us to delete and re-add it, which would result in the
858
        # row in the child table being deleted.
859
        $simplemail = $this->getSetting('simplemail', NULL);
457✔
860

861
        $emailfrequency = 24;
457✔
862
        $eventsallowed = 1;
457✔
863
        $volunteeringallowed = 1;
457✔
864

865
        switch ($simplemail) {
866
            case User::SIMPLE_MAIL_NONE: {
867
                $emailfrequency = 0;
1✔
868
                $eventsallowed = 0;
1✔
869
                $volunteeringallowed = 0;
1✔
870
                break;
1✔
871
            }
872

873
            case User::SIMPLE_MAIL_BASIC: {
874
                $emailfrequency = 24;
×
875
                $eventsallowed = 0;
×
876
                $volunteeringallowed = 0;
×
877
                break;
×
878
            }
879

880
            case User::SIMPLE_MAIL_FULL: {
881
                $emailfrequency = -1;
×
882
                $eventsallowed = 1;
×
883
                $volunteeringallowed = 1;
×
884
                break;
×
885
            }
886
        }
887

888
        $rc = $this->dbhm->preExec("INSERT INTO memberships (userid, groupid, role, collection, emailfrequency, eventsallowed, volunteeringallowed) VALUES (?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), role = ?, collection = ?, emailfrequency = ?, eventsallowed = ?, volunteeringallowed = ?;", [
457✔
889
            $this->id,
457✔
890
            $groupid,
457✔
891
            $role,
457✔
892
            $collection,
457✔
893
            $emailfrequency,
457✔
894
            $eventsallowed,
457✔
895
            $volunteeringallowed,
457✔
896
            $role,
457✔
897
            $collection,
457✔
898
            $emailfrequency,
457✔
899
            $eventsallowed,
457✔
900
            $volunteeringallowed
457✔
901
        ]);
457✔
902

903
        $membershipid = $this->dbhm->lastInsertId();
457✔
904

905
        # We added it if it wasn't there before and the INSERT worked.
906
        $added = $this->dbhm->rowsAffected() && $existing[0]['count'] == 0;
457✔
907

908
        # Record the operation for abuse detection.  Setting processingrequired will cause background code to do
909
        # work.
910
        $this->dbhm->preExec("INSERT INTO memberships_history (userid, groupid, collection, processingrequired) VALUES (?,?,?,?);", [
457✔
911
            $this->id,
457✔
912
            $groupid,
457✔
913
            $collection,
457✔
914
            $added
457✔
915
        ]);
457✔
916

917
        $historyid = $this->dbhm->lastInsertId();
457✔
918

919
        # We might need to update the systemrole.
920
        #
921
        # Not the end of the world if this fails.
922
        $this->updateSystemRole($role);
457✔
923

924
        // @codeCoverageIgnoreStart
925
        if ($byemail) {
926
            list ($transport, $mailer) = Mail::getMailer();
927
            $message = \Swift_Message::newInstance()
928
                ->setSubject("Welcome to " . $g->getPrivate('nameshort'))
929
                ->setFrom($g->getAutoEmail())
930
                ->setReplyTo($g->getModsEmail())
931
                ->setTo($byemail)
932
                ->setDate(time())
933
                ->setBody("Pleased to meet you.");
934

935
            Mail::addHeaders($this->dbhr, $this->dbhm, $message,Mail::WELCOME, $this->id);
936

937
            $this->sendIt($mailer, $message);
938
        }
939
        // @codeCoverageIgnoreEnd
940

941
        if ($added) {
457✔
942
            $l = new Log($this->dbhr, $this->dbhm);
457✔
943
            $text = NULL;
457✔
944

945
            if ($manual !== NULL) {
457✔
946
                $text = $manual ? 'Manual' : 'Auto';
4✔
947
            }
948

949
            $l->log([
457✔
950
                'type' => Log::TYPE_GROUP,
457✔
951
                'subtype' => Log::SUBTYPE_JOINED,
457✔
952
                'user' => $this->id,
457✔
953
                'byuser' => $me ? $me->getId() : NULL,
457✔
954
                'groupid' => $groupid,
457✔
955
                'text' => $text
457✔
956
            ]);
457✔
957
        }
958

959
        return ($rc);
457✔
960
    }
961

962
    public function cacheMemberships($id = NULL)
963
    {
964
        $id = $id ? $id : $this->id;
343✔
965

966
        # We get all the memberships in a single call, because some members are on many groups and this can
967
        # save hundreds of calls to the DB.
968
        if (!$this->memberships) {
343✔
969
            $this->memberships = [];
343✔
970

971
            $membs = $this->dbhr->preQuery("SELECT memberships.*, groups.type FROM memberships INNER JOIN `groups` ON groups.id = memberships.groupid WHERE userid = ?;", [ $id ]);
343✔
972
            foreach ($membs as $memb) {
343✔
973
                $this->memberships[$memb['groupid']] = $memb;
311✔
974
            }
975
        }
976

977
        return ($this->memberships);
343✔
978
    }
979

980
    public function clearMembershipCache()
981
    {
982
        $this->memberships = NULL;
309✔
983
    }
984

985
    public function getMembershipAtt($groupid, $att)
986
    {
987
        $this->cacheMemberships();
189✔
988
        $val = NULL;
189✔
989
        if (Utils::pres($groupid, $this->memberships)) {
189✔
990
            $val = Utils::presdef($att, $this->memberships[$groupid], NULL);
184✔
991
        }
992

993
        return ($val);
189✔
994
    }
995

996
    public function setMembershipAtt($groupid, $att, $val)
997
    {
998
        $this->clearMembershipCache();
251✔
999
        Session::clearSessionCache();
251✔
1000
        $sql = "UPDATE memberships SET $att = ? WHERE groupid = ? AND userid = ?;";
251✔
1001
        $rc = $this->dbhm->preExec($sql, [
251✔
1002
            $val,
251✔
1003
            $groupid,
251✔
1004
            $this->id
251✔
1005
        ]);
251✔
1006

1007
        return ($rc);
251✔
1008
    }
1009

1010
    public function setMembershipAttId($id, $att, $val)
1011
    {
1012
        $this->clearMembershipCache();
1✔
1013
        Session::clearSessionCache();
1✔
1014
        $sql = "UPDATE memberships SET $att = ? WHERE id = ?;";
1✔
1015
        $rc = $this->dbhm->preExec($sql, [
1✔
1016
            $val,
1✔
1017
            $id
1✔
1018
        ]);
1✔
1019

1020
        return ($rc);
1✔
1021
    }
1022

1023
    public function removeMembership($groupid, $ban = FALSE, $spam = FALSE, $byemail = NULL)
1024
    {
1025
        $this->clearMembershipCache();
40✔
1026
        $g = Group::get($this->dbhr, $this->dbhm, $groupid);
40✔
1027
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
40✔
1028
        $meid = $me ? $me->getId() : NULL;
40✔
1029

1030
        // @codeCoverageIgnoreStart
1031
        //
1032
        // Let them know.  We always want to let TN know if a member is removed/banned so that they can't see
1033
        // the messages.
1034
        if ($byemail || $this->isTN()) {
1035
            list ($transport, $mailer) = Mail::getMailer();
1036
            $message = \Swift_Message::newInstance()
1037
                ->setSubject("Farewell from " . $g->getPrivate('nameshort'))
1038
                ->setFrom($g->getAutoEmail())
1039
                ->setReplyTo($g->getModsEmail())
1040
                ->setTo($this->getEmailPreferred())
1041
                ->setDate(time())
1042
                ->setBody("Parting is such sweet sorrow.");
1043

1044
            Mail::addHeaders($this->dbhr, $this->dbhm, $message,Mail::REMOVED, $this->id);
1045

1046
            $this->sendIt($mailer, $message);
1047
        }
1048
        // @codeCoverageIgnoreEnd
1049

1050
        if ($ban) {
40✔
1051
            $sql = "INSERT IGNORE INTO users_banned (userid, groupid, byuser) VALUES (?,?,?);";
13✔
1052
            $this->dbhm->preExec($sql, [
13✔
1053
                $this->id,
13✔
1054
                $groupid,
13✔
1055
                $meid
13✔
1056
            ]);
13✔
1057

1058
            # Mark any messages on this group as withdrawn.  Not strictly TRUE, but it will stop people replying
1059
            # while keeping the messages around for stats purposes and in case we want to look at them.
1060
            $msgs = $this->dbhr->preQuery("SELECT messages_groups.msgid FROM messages_groups 
13✔
1061
    INNER JOIN messages ON messages_groups.msgid = messages.id 
1062
    LEFT JOIN messages_outcomes ON messages_outcomes.msgid = messages_groups.msgid 
1063
    WHERE messages.fromuser = ? AND messages_groups.groupid = ? AND messages.type IN (?, ?);", [
13✔
1064
                $this->id,
13✔
1065
                $groupid,
13✔
1066
                Message::TYPE_OFFER,
13✔
1067
                Message::TYPE_WANTED
13✔
1068
            ]);
13✔
1069

1070
            foreach ($msgs as $msg) {
13✔
1071
                $m = new Message($this->dbhr, $this->dbhm, $msg['msgid']);
1✔
1072
                if (!$m->hasOutcome()) {
1✔
1073
                    $m->mark(Message::OUTCOME_WITHDRAWN, "Marked as withdrawn by ban", NULL, NULL);
1✔
1074
                }
1075
            }
1076
        }
1077

1078
        # Now remove the membership.
1079
        $rc = $this->dbhm->preExec("DELETE FROM memberships WHERE userid = ? AND groupid = ?;",
40✔
1080
            [
40✔
1081
                $this->id,
40✔
1082
                $groupid
40✔
1083
            ]);
40✔
1084

1085
        if ($this->dbhm->rowsAffected() || $ban) {
40✔
1086
            $l = new Log($this->dbhr, $this->dbhm);
40✔
1087
            $l->log([
40✔
1088
                'type' => Log::TYPE_GROUP,
40✔
1089
                'subtype' => Log::SUBTYPE_LEFT,
40✔
1090
                'user' => $this->id,
40✔
1091
                'byuser' => $meid,
40✔
1092
                'groupid' => $groupid,
40✔
1093
                'text' => $spam ? "Autoremoved spammer" : ($ban ? "via ban" : NULL)
40✔
1094
            ]);
40✔
1095
        }
1096

1097
        return ($rc);
40✔
1098
    }
1099

1100
    public function getMembershipGroupIds($modonly = FALSE, $grouptype = NULL, $id = NULL) {
1101
        $id = $id ? $id : $this->id;
4✔
1102

1103
        $ret = [];
4✔
1104
        $modq = $modonly ? " AND role IN ('Owner', 'Moderator') " : "";
4✔
1105
        $typeq = $grouptype ? (" AND `type` = " . $this->dbhr->quote($grouptype)) : '';
4✔
1106
        $publishq = Session::modtools() ? "" : "AND groups.publish = 1";
4✔
1107
        $sql = "SELECT groupid FROM memberships INNER JOIN `groups` ON groups.id = memberships.groupid $publishq WHERE userid = ? $modq $typeq ORDER BY memberships.added DESC;";
4✔
1108
        $groups = $this->dbhr->preQuery($sql, [$id]);
4✔
1109
        #error_log("getMemberships $sql {$id} " . var_export($groups, TRUE));
1110
        $groupids = array_filter(array_column($groups, 'groupid'));
4✔
1111
        return $groupids;
4✔
1112
    }
1113

1114
    public function getMemberships($modonly = FALSE, $grouptype = NULL, $getwork = FALSE, $pernickety = FALSE, $id = NULL)
1115
    {
1116
        $id = $id ? $id : $this->id;
140✔
1117

1118
        $ret = [];
140✔
1119
        $modq = $modonly ? " AND role IN ('Owner', 'Moderator') " : "";
140✔
1120
        $typeq = $grouptype ? (" AND `type` = " . $this->dbhr->quote($grouptype)) : '';
140✔
1121
        $publishq = Session::modtools() ? "" : "AND groups.publish = 1";
140✔
1122
        $sql = "SELECT type, memberships.heldby, memberships.settings, collection, emailfrequency, eventsallowed, volunteeringallowed, groupid, role, configid, ourPostingStatus, CASE WHEN namefull IS NOT NULL THEN namefull ELSE nameshort END AS namedisplay FROM memberships INNER JOIN `groups` ON groups.id = memberships.groupid $publishq WHERE userid = ? $modq $typeq ORDER BY LOWER(namedisplay) ASC;";
140✔
1123
        $groups = $this->dbhr->preQuery($sql, [$id]);
140✔
1124
        #error_log("getMemberships $sql {$id} " . var_export($groups, TRUE));
1125

1126
        $c = new ModConfig($this->dbhr, $this->dbhm);
140✔
1127

1128
        # Get all the groups efficiently.
1129
        $groupids = array_filter(array_column($groups, 'groupid'));
140✔
1130
        $gc = new GroupCollection($this->dbhr, $this->dbhm, $groupids);
140✔
1131
        $groupobjs = $gc->get();
140✔
1132
        $getworkids = [];
140✔
1133
        $groupsettings = [];
140✔
1134

1135
        for ($i = 0; $i < count($groupids); $i++) {
140✔
1136
            $group = $groups[$i];
109✔
1137
            $g = $groupobjs[$i];
109✔
1138
            $one = $g->getPublic();
109✔
1139

1140
            $one['role'] = $group['role'];
109✔
1141
            $one['collection'] = $group['collection'];
109✔
1142
            $amod = ($one['role'] == User::ROLE_MODERATOR || $one['role'] == User::ROLE_OWNER);
109✔
1143
            $one['configid'] = Utils::presdef('configid', $group, NULL);
109✔
1144

1145
            if ($amod && !Utils::pres('configid', $one)) {
109✔
1146
                # Get a config using defaults.
1147
                $one['configid'] = $c->getForGroup($id, $group['groupid']);
33✔
1148
            }
1149

1150
            $one['mysettings'] = $this->getGroupSettings($group['groupid'], Utils::presdef('configid', $one, NULL), $id);
109✔
1151

1152
            # If we don't have our own email on this group we won't be sending mails.  This is what affects what
1153
            # gets shown on the Settings page for the user, and we only want to check this here
1154
            # for performance reasons.
1155
            $one['mysettings']['emailfrequency'] = ($group['type'] ==  Group::GROUP_FREEGLE &&
109✔
1156
                ($pernickety || $this->sendOurMails($g, FALSE, FALSE))) ?
109✔
1157
                (array_key_exists('emailfrequency', $one['mysettings']) ? $one['mysettings']['emailfrequency'] :  24)
70✔
1158
                : 0;
40✔
1159

1160
            $groupsettings[$group['groupid']] = $one['mysettings'];
109✔
1161

1162
            if ($getwork) {
109✔
1163
                # We need to find out how much work there is whether or not we are an active mod because we need
1164
                # to be able to see that it is there.  The UI shows it less obviously.
1165
                if ($amod) {
23✔
1166
                    $getworkids[] = $group['groupid'];
21✔
1167
                }
1168
            }
1169

1170
            $ret[] = $one;
109✔
1171
        }
1172

1173
        if ($getwork) {
140✔
1174
            # Get all the work.  This is across all groups for performance.
1175
            $g = new Group($this->dbhr, $this->dbhm);
29✔
1176
            $work = $g->getWorkCounts($groupsettings, $groupids);
29✔
1177

1178
            foreach ($getworkids as $groupid) {
29✔
1179
                foreach ($ret as &$group) {
21✔
1180
                    if ($group['id'] == $groupid) {
21✔
1181
                        $group['work'] = $work[$groupid];
21✔
1182
                    }
1183
                }
1184
            }
1185

1186
            # We might have been returned extra group info for wider chat review.  Add any extra groups from the work
1187
            # counts, in a basic way, so that the work counts appear.
1188
            #
1189
            # Find groupids in $work which are not in $ret.
1190
            $existingids = [];
29✔
1191
            foreach ($ret as $r) {
29✔
1192
                $existingids[] = $r['id'];
23✔
1193
            }
1194

1195

1196
            $extraworkids = [];
29✔
1197

1198
            foreach ($work as $gid => $w) {
29✔
1199
                if (!in_array($gid, $existingids)) {
23✔
1200
                    $extraworkids[] = $gid;
1✔
1201
                }
1202
            }
1203

1204
            # We have some subtle and baffling reference thing going on which is trashing the array.
1205
            # Do a json_encode/decode to remove them.
1206
            $ret = json_decode(json_encode($ret), TRUE);
29✔
1207
            foreach ($extraworkids as $groupid) {
29✔
1208
                $g = Group::get($this->dbhr, $this->dbhm, $groupid);
1✔
1209
                $group = $g->getPublic($groupid, TRUE);
1✔
1210
                $group['work'] = $work[$groupid];
1✔
1211
                $group['role'] = User::ROLE_NONMEMBER;
1✔
1212
                $ret[] = $group;
1✔
1213
            }
1214
        }
1215

1216
        return ($ret);
140✔
1217
    }
1218

1219
    public function getConfigs($all)
1220
    {
1221
        $ret = [];
22✔
1222
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
22✔
1223

1224
        if ($all) {
22✔
1225
            # We can see configs which
1226
            # - we created
1227
            # - are used by mods on groups on which we are a mod
1228
            # - defaults
1229
            $modships = $me ? $this->getModeratorships() : [];
21✔
1230
            $modships = count($modships) > 0 ? $modships : [0];
21✔
1231

1232
            $sql = "SELECT DISTINCT id FROM ((SELECT configid AS id FROM memberships WHERE groupid IN (" . implode(',', $modships) . ") AND role IN ('Owner', 'Moderator') AND configid IS NOT NULL) UNION (SELECT id FROM mod_configs WHERE createdby = {$this->id} OR `default` = 1)) t;";
21✔
1233
            $ids = $this->dbhr->preQuery($sql);
21✔
1234
        } else {
1235
            # We only want to see the configs that we are actively using.  This reduces the size of what we return
1236
            # for people on many groups.
1237
            $sql = "SELECT DISTINCT configid AS id FROM memberships WHERE userid = ? AND configid IS NOT NULL;";
3✔
1238
            $ids = $this->dbhr->preQuery($sql, [ $me->getId() ]);
3✔
1239
        }
1240

1241
        $configids = array_filter(array_column($ids, 'id'));
22✔
1242

1243
        if ($configids) {
22✔
1244
            # Get all the info we need for the modconfig object in a single SELECT for performance.  This is particularly
1245
            # valuable for people on many groups and therefore with access to many modconfigs.
1246
            $sql = "SELECT DISTINCT mod_configs.*, 
4✔
1247
        CASE WHEN users.fullname IS NOT NULL THEN users.fullname ELSE CONCAT(users.firstname, ' ', users.lastname) END AS createdname 
1248
        FROM mod_configs LEFT JOIN users ON users.id = mod_configs.createdby
1249
        WHERE mod_configs.id IN (" . implode(',', $configids) . ");";
4✔
1250
            $configs = $this->dbhr->preQuery($sql);
4✔
1251

1252
            # Also get all the bulk ops and standard messages, again for performance.
1253
            $stdmsgs = $this->dbhr->preQuery("SELECT DISTINCT * FROM mod_stdmsgs WHERE configid IN (" . implode(',', $configids) . ");");
4✔
1254
            $bulkops = $this->dbhr->preQuery("SELECT * FROM mod_bulkops WHERE configid IN (" . implode(',', $configids) . ");");
4✔
1255

1256
            foreach ($configs as $config) {
4✔
1257
                $c = new ModConfig($this->dbhr, $this->dbhm, $config['id'], $config, $stdmsgs, $bulkops);
4✔
1258
                $thisone = $c->getPublic(FALSE);
4✔
1259

1260
                if (Utils::pres('createdby', $config)) {
4✔
1261
                    $ctx = NULL;
3✔
1262
                    $thisone['createdby'] = [
3✔
1263
                        'id' => $config['createdby'],
3✔
1264
                        'displayname' => $config['createdname']
3✔
1265
                    ];
3✔
1266
                }
1267

1268
                $ret[] = $thisone;
4✔
1269
            }
1270
        }
1271

1272
        # Return in alphabetical order.
1273
        $rc = usort($ret, function ($a, $b) {
22✔
1274
            return strcasecmp($a['name'], $b['name']);
1✔
1275
        });
22✔
1276

1277
        return ($ret);
22✔
1278
    }
1279

1280
    public function getModeratorships($id = NULL, $activeonly = FALSE)
1281
    {
1282
        $this->cacheMemberships($id);
150✔
1283
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
150✔
1284

1285
        $ret = [];
150✔
1286
        foreach ($this->memberships AS $membership) {
150✔
1287
            if ($membership['role'] == 'Owner' || $membership['role'] == 'Moderator') {
124✔
1288
                if (!$activeonly || $me->activeModForGroup($membership['groupid'])) {
92✔
1289
                    $ret[] = $membership['groupid'];
92✔
1290
                }
1291
            }
1292
        }
1293

1294
        return ($ret);
150✔
1295
    }
1296

1297
    public function isModOrOwner($groupid)
1298
    {
1299
        # Very frequently used.  Cache in session.
1300
        #error_log("modOrOwner " . var_export($_SESSION['modorowner'], TRUE));
1301
        if ((session_status() !== PHP_SESSION_NONE || getenv('UT')) &&
179✔
1302
            array_key_exists('modorowner', $_SESSION) &&
179✔
1303
            array_key_exists($this->id, $_SESSION['modorowner']) &&
179✔
1304
            array_key_exists($groupid, $_SESSION['modorowner'][$this->id])) {
179✔
1305
            return ($_SESSION['modorowner'][$this->id][$groupid]);
29✔
1306
        } else {
1307
            $sql = "SELECT groupid FROM memberships WHERE userid = ? AND role IN ('Moderator', 'Owner') AND groupid = ?;";
179✔
1308
            #error_log("$sql {$this->id}, $groupid");
1309
            $groups = $this->dbhr->preQuery($sql, [
179✔
1310
                $this->id,
179✔
1311
                $groupid
179✔
1312
            ]);
179✔
1313

1314
            foreach ($groups as $group) {
179✔
1315
                $_SESSION['modorowner'][$this->id][$groupid] = TRUE;
41✔
1316
                return TRUE;
41✔
1317
            }
1318

1319
            $_SESSION['modorowner'][$this->id][$groupid] = FALSE;
164✔
1320
            return (FALSE);
164✔
1321
        }
1322
    }
1323

1324
    public function getLogins($credentials = TRUE, $id = NULL, $excludelink = FALSE)
1325
    {
1326
        $excludelinkq = $excludelink ? (" AND type != '" . User::LOGIN_LINK . "'") : '';
304✔
1327

1328
        $logins = $this->dbhr->preQuery("SELECT * FROM users_logins WHERE userid = ? $excludelinkq ORDER BY lastaccess DESC;",
304✔
1329
            [$id ? $id : $this->id]);
304✔
1330

1331
        foreach ($logins as &$login) {
304✔
1332
            if (!$credentials) {
299✔
1333
                unset($login['credentials']);
23✔
1334
            }
1335
            $login['added'] = Utils::ISODate($login['added']);
299✔
1336
            $login['lastaccess'] = Utils::ISODate($login['lastaccess']);
299✔
1337
            $login['uid'] = '' . $login['uid'];
299✔
1338
        }
1339

1340
        return ($logins);
304✔
1341
    }
1342

1343
    public function findByLogin($type, $uid)
1344
    {
1345
        $logins = $this->dbhr->preQuery("SELECT * FROM users_logins WHERE uid = ? AND type = ?;",
8✔
1346
            [$uid, $type]);
8✔
1347

1348
        foreach ($logins as $login) {
8✔
1349
            return ($login['userid']);
4✔
1350
        }
1351

1352
        return (NULL);
8✔
1353
    }
1354

1355
    public function addLogin($type, $uid, $creds = NULL, $salt = PASSWORD_SALT)
1356
    {
1357
        if ($type == User::LOGIN_NATIVE) {
515✔
1358
            # Native login - encrypt the password a bit.  The password salt is global in FD, but per-login for users
1359
            # migrated from Norfolk.
1360
            $creds = $this->hashPassword($creds, $salt);
513✔
1361
            $uid = $this->id;
513✔
1362
        }
1363

1364
        # If the login with this type already exists in the table, that's fine.
1365
        $sql = "INSERT INTO users_logins (userid, uid, type, credentials, salt) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE credentials = ?, salt = ?;";
515✔
1366
        $rc = $this->dbhm->preExec($sql,
515✔
1367
            [$this->id, $uid, $type, $creds, $salt, $creds, $salt]);
515✔
1368

1369
        # If we add a login, we might be about to log in.
1370
        # TODO This is a bit hacky.
1371
        global $sessionPrepared;
1372
        $sessionPrepared = FALSE;
515✔
1373

1374
        return ($rc);
515✔
1375
    }
1376

1377
    public function removeLogin($type, $uid)
1378
    {
1379
        $rc = $this->dbhm->preExec("DELETE FROM users_logins WHERE userid = ? AND type = ? AND uid = ?;",
4✔
1380
            [$this->id, $type, $uid]);
4✔
1381
        return ($rc);
4✔
1382
    }
1383

1384
    public function getRoleForGroup($groupid, $overrides = TRUE)
1385
    {
1386
        # We can have a number of roles on a group
1387
        # - none, we can only see what is member
1388
        # - member, we are a group member and can see some extra info
1389
        # - moderator, we can see most info on a group
1390
        # - owner, we can see everything
1391
        #
1392
        # If our system role is support then we get moderator status; if it's admin we get owner status.
1393
        $role = User::ROLE_NONMEMBER;
73✔
1394

1395
        if ($overrides) {
73✔
1396
            switch ($this->getPrivate('systemrole')) {
32✔
1397
                case User::SYSTEMROLE_SUPPORT:
1398
                    $role = User::ROLE_MODERATOR;
3✔
1399
                    break;
3✔
1400
                case User::SYSTEMROLE_ADMIN:
1401
                    $role = User::ROLE_OWNER;
1✔
1402
                    break;
1✔
1403
            }
1404
        }
1405

1406
        # Now find if we have any membership of the group which might also give us a role.
1407
        $membs = $this->dbhr->preQuery("SELECT role FROM memberships WHERE userid = ? AND groupid = ?;", [
73✔
1408
            $this->id,
73✔
1409
            $groupid
73✔
1410
        ]);
73✔
1411

1412
        foreach ($membs as $memb) {
73✔
1413
            switch ($memb['role']) {
70✔
1414
                case 'Moderator':
70✔
1415
                    # Don't downgrade from owner if we have that by virtue of an override.
1416
                    $role = $role == User::ROLE_OWNER ? $role : User::ROLE_MODERATOR;
36✔
1417
                    break;
36✔
1418
                case 'Owner':
61✔
1419
                    $role = User::ROLE_OWNER;
8✔
1420
                    break;
8✔
1421
                case 'Member':
59✔
1422
                    # Don't downgrade if we already have a role by virtue of an override.
1423
                    $role = $role == User::ROLE_NONMEMBER ? User::ROLE_MEMBER : $role;
59✔
1424
                    break;
59✔
1425
            }
1426
        }
1427

1428
        return ($role);
73✔
1429
    }
1430

1431
    public function moderatorForUser($userid, $allowmod = FALSE)
1432
    {
1433
        # There are times when we want to check whether we can administer a user, but when we are not immediately
1434
        # within the context of a known group.  We can administer a user when:
1435
        # - they're only a user themselves
1436
        # - we are a mod on one of the groups on which they are a member.
1437
        # - it's us
1438
        if ($userid != $this->getId()) {
15✔
1439
            $u = User::get($this->dbhr, $this->dbhm, $userid);
12✔
1440

1441
            $usermemberships = [];
12✔
1442
            $modq = $allowmod ? ", 'Moderator', 'Owner'" : '';
12✔
1443
            $groups = $this->dbhr->preQuery("SELECT groupid FROM memberships WHERE userid = ? AND role IN ('Member' $modq);", [$userid]);
12✔
1444
            foreach ($groups as $group) {
12✔
1445
                $usermemberships[] = $group['groupid'];
8✔
1446
            }
1447

1448
            $mymodships = $this->getModeratorships();
12✔
1449

1450
            # Is there any group which we mod and which they are a member of?
1451
            $canmod = count(array_intersect($usermemberships, $mymodships)) > 0;
12✔
1452
        } else {
1453
            $canmod = TRUE;
5✔
1454
        }
1455

1456
        return ($canmod);
15✔
1457
    }
1458

1459
    public function getSetting($setting, $default)
1460
    {
1461
        $ret = $default;
461✔
1462
        $s = $this->getPrivate('settings');
461✔
1463

1464
        if ($s) {
461✔
1465
            $settings = json_decode($s, TRUE);
25✔
1466
            $ret = array_key_exists($setting, $settings) ? $settings[$setting] : $default;
25✔
1467
        }
1468

1469
        return ($ret);
461✔
1470
    }
1471

1472
    public function setSetting($setting, $val)
1473
    {
1474
        $s = $this->getPrivate('settings');
25✔
1475

1476
        if ($s) {
25✔
1477
            $settings = json_decode($s, TRUE);
1✔
1478
        } else {
1479
            $settings = [];
25✔
1480
        }
1481

1482
        $settings[$setting] = $val;
25✔
1483
        $this->setPrivate('settings', json_encode($settings));
25✔
1484
    }
1485

1486
    public function setGroupSettings($groupid, $settings)
1487
    {
1488
        $this->clearMembershipCache();
6✔
1489
        $sql = "UPDATE memberships SET settings = ? WHERE userid = ? AND groupid = ?;";
6✔
1490
        return ($this->dbhm->preExec($sql, [
6✔
1491
            json_encode($settings),
6✔
1492
            $this->id,
6✔
1493
            $groupid
6✔
1494
        ]));
6✔
1495
    }
1496

1497
    public function activeModForGroup($groupid, $mysettings = NULL)
1498
    {
1499
        $mysettings = $mysettings ? $mysettings : $this->getGroupSettings($groupid);
33✔
1500

1501
        # If we have the active flag use that; otherwise assume that the legacy showmessages flag tells us.  Default
1502
        # to active.
1503
        # TODO Retire showmessages entirely and remove from user configs.
1504
        $active = array_key_exists('active', $mysettings) ? $mysettings['active'] : (!array_key_exists('showmessages', $mysettings) || $mysettings['showmessages']);
33✔
1505
        return ($active);
33✔
1506
    }
1507

1508
    public function widerReview() {
1509
        # Check if we are participating in wider chat review, i.e. we are a mod on a group with that setting.
1510
        $modships = $this->getModeratorships();
22✔
1511
        $widerreview = FALSE;
22✔
1512

1513
        foreach ($modships as $mod) {
22✔
1514
            if ($this->activeModForGroup($mod)) {
19✔
1515
                $g = Group::get($this->dbhr, $this->dbhm, $mod);
19✔
1516
                if ($g->getSetting('widerchatreview', FALSE)) {
19✔
1517
                    $widerreview = TRUE;
1✔
1518
                    break;
1✔
1519
                }
1520
            }
1521
        }
1522

1523
        return $widerreview;
22✔
1524
    }
1525

1526
    public function getGroupSettings($groupid, $configid = NULL, $id = NULL)
1527
    {
1528
        $id = $id ? $id : $this->id;
137✔
1529

1530
        # We have some parameters which may give us some info which saves queries
1531
        $this->cacheMemberships($id);
137✔
1532

1533
        # Defaults match memberships ones in Group.php.
1534
        $defaults = [
137✔
1535
            'active' => 1,
137✔
1536
            'showchat' => 1,
137✔
1537
            'pushnotify' => 1,
137✔
1538
            'eventsallowed' => 1,
137✔
1539
            'volunteeringallowed' => 1
137✔
1540
        ];
137✔
1541

1542
        $settings = $defaults;
137✔
1543

1544
        if (Utils::pres($groupid, $this->memberships)) {
137✔
1545
            $set = $this->memberships[$groupid];
137✔
1546

1547
            if ($set['settings']) {
137✔
1548
                $settings = json_decode($set['settings'], TRUE);
5✔
1549

1550
                if (!$configid && ($set['role'] == User::ROLE_OWNER || $set['role'] == User::ROLE_MODERATOR)) {
5✔
1551
                    $c = new ModConfig($this->dbhr, $this->dbhm);
5✔
1552

1553
                    # We might have an explicit configid - if so, use it to save on DB calls.
1554
                    $settings['configid'] = $set['configid'] ? $set['configid'] : $c->getForGroup($this->id, $groupid);
5✔
1555
                }
1556
            }
1557

1558
            # Base active setting on legacy showmessages setting if not present.
1559
            $settings['active'] = array_key_exists('active', $settings) ? $settings['active'] : (!array_key_exists('showmessages', $settings) || $settings['showmessages']);
137✔
1560
            $settings['active'] = $settings['active'] ? 1 : 0;
137✔
1561

1562
            foreach ($defaults as $key => $val) {
137✔
1563
                if (!array_key_exists($key, $settings)) {
137✔
1564
                    $settings[$key] = $val;
5✔
1565
                }
1566
            }
1567

1568
            $settings['emailfrequency'] = $set['emailfrequency'];
137✔
1569
            $settings['eventsallowed'] = $set['eventsallowed'];
137✔
1570
            $settings['volunteeringallowed'] = $set['volunteeringallowed'];
137✔
1571
        }
1572

1573
        return ($settings);
137✔
1574
    }
1575

1576
    public function setRole($role, $groupid)
1577
    {
1578
        $rc = TRUE;
47✔
1579
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
47✔
1580

1581
        Session::clearSessionCache();
47✔
1582

1583
        $currentRole = $this->getRoleForGroup($groupid, FALSE);
47✔
1584

1585
        if ($currentRole != $role) {
47✔
1586
            $l = new Log($this->dbhr, $this->dbhm);
47✔
1587
            $l->log([
47✔
1588
                        'type' => Log::TYPE_USER,
47✔
1589
                        'byuser' => $me ? $me->getId() : NULL,
47✔
1590
                        'subtype' => Log::SUBTYPE_ROLE_CHANGE,
47✔
1591
                        'groupid' => $groupid,
47✔
1592
                        'user' => $this->id,
47✔
1593
                        'text' => $role
47✔
1594
                    ]);
47✔
1595

1596
            $this->clearMembershipCache();
47✔
1597
            $sql = "UPDATE memberships SET role = ? WHERE userid = ? AND groupid = ?;";
47✔
1598
            $rc = $this->dbhm->preExec($sql, [
47✔
1599
                $role,
47✔
1600
                $this->id,
47✔
1601
                $groupid
47✔
1602
            ]);
47✔
1603

1604
            # We might need to update the systemrole.
1605
            #
1606
            # Not the end of the world if this fails.
1607
            $this->updateSystemRole($role);
47✔
1608

1609
            if ($currentRole == User::ROLE_MEMBER) {
47✔
1610
                # We have promoted this member.  We want to ensure that they have no unread old chats.
1611
                $r = new ChatRoom($this->dbhr, $this->dbhm);
44✔
1612
                $r->upToDateAll($this->getId(),[
44✔
1613
                    ChatRoom::TYPE_USER2MOD
44✔
1614
                ]);
44✔
1615
            } else if (($currentRole == User::ROLE_MODERATOR || $currentRole == User::ROLE_OWNER) && $role == User::ROLE_MEMBER) {
17✔
1616
                # This member has been demoted.  Mail the other mods.
1617
                $g = Group::get($this->dbhr, $this->dbhm, $groupid);
15✔
1618
                $g->notifyAboutSignificantEvent($this->getName() . " is no longer a moderator on " . $g->getPrivate('nameshort'),
15✔
1619
                    "If this is unexpected, please contact them and/or check that there are enough volunteers on this group.  If you need help, please contact ". MENTORS_ADDR . "."
15✔
1620
                );
15✔
1621
            }
1622

1623
            $this->memberships = NULL;
47✔
1624
        }
1625

1626
        return ($rc);
47✔
1627
    }
1628

1629
    public function getActiveCounts() {
1630
        $users = [
1✔
1631
            $this->id => [
1✔
1632
                'id' => $this->id
1✔
1633
            ]];
1✔
1634

1635
        $this->getActiveCountss($users);
1✔
1636
        return($users[$this->id]['activecounts']);
1✔
1637
    }
1638

1639
    public function getActiveCountss(&$users) {
1640
        $start = date('Y-m-d', strtotime(User::OPEN_AGE . " days ago"));
17✔
1641
        $uids = array_filter(array_column($users, 'id'));
17✔
1642

1643
        if (count($uids)) {
17✔
1644
            $counts = $this->dbhr->preQuery("SELECT messages.fromuser AS userid, COUNT(*) AS count, messages.type, messages_outcomes.outcome FROM messages LEFT JOIN messages_outcomes ON messages_outcomes.msgid = messages.id INNER JOIN messages_groups ON messages_groups.msgid = messages.id WHERE fromuser IN (" . implode(',', $uids) . ") AND messages_groups.arrival > ? AND collection = ? AND messages_groups.deleted = 0 AND messages_outcomes.id IS NULL GROUP BY messages.fromuser, messages.type, messages_outcomes.outcome;", [
17✔
1645
                $start,
17✔
1646
                MessageCollection::APPROVED
17✔
1647
            ]);
17✔
1648

1649
            foreach ($users as $user) {
17✔
1650
                $offers = 0;
17✔
1651
                $wanteds = 0;
17✔
1652

1653
                foreach ($counts as $count) {
17✔
1654
                    if ($count['userid'] == $user['id']) {
1✔
1655
                        if ($count['type'] == Message::TYPE_OFFER) {
1✔
1656
                            $offers += $count['count'];
1✔
1657
                        } else if ($count['type'] == Message::TYPE_WANTED) {
1✔
1658
                            $wanteds += $count['count'];
1✔
1659
                        }
1660
                    }
1661
                }
1662

1663
                $users[$user['id']]['activecounts'] = [
17✔
1664
                    'offers' => $offers,
17✔
1665
                    'wanteds' => $wanteds
17✔
1666
                ];
17✔
1667
            }
1668
        }
1669
    }
1670

1671
    public function getInfos(&$users, $grace = ChatRoom::REPLY_GRACE) {
1672
        $uids = array_filter(array_column($users, 'id'));
130✔
1673

1674
        $start = date('Y-m-d', strtotime(User::OPEN_AGE . " days ago"));
130✔
1675
        $days90 = date("Y-m-d", strtotime("90 days ago"));
130✔
1676
        $userq = "userid IN (" . implode(',', $uids) . ")";
130✔
1677

1678
        foreach ($uids as $uid) {
130✔
1679
            $users[$uid]['info']['replies'] = 0;
130✔
1680
            $users[$uid]['info']['taken'] = 0;
130✔
1681
            $users[$uid]['info']['reneged'] = 0;
130✔
1682
            $users[$uid]['info']['collected'] = 0;
130✔
1683
            $users[$uid]['info']['openage'] = User::OPEN_AGE;
130✔
1684
        }
1685

1686
        // We can combine some queries into a single one.  This is better for performance because it saves on
1687
        // the round trip (seriously, I've measured it, and it's worth doing).
1688
        //
1689
        // No need to check on the chat room type as we can only get messages of type Interested in a User2User chat.
1690
        $tq = Session::modtools() ? ", t6.*, t7.*" : '';
130✔
1691
        $sql = "SELECT t0.id AS theuserid, t0.lastaccess AS lastaccess, t1.*, t3.*, t4.*, t5.* $tq FROM
130✔
1692
(SELECT id, lastaccess FROM users WHERE id in (" . implode(',', $uids) . ")) t0 LEFT JOIN                                                                
130✔
1693
(SELECT COUNT(DISTINCT refmsgid) AS replycount, userid FROM chat_messages WHERE $userq AND date > ? AND refmsgid IS NOT NULL AND type = ?) t1 ON t1.userid = t0.id LEFT JOIN";
130✔
1694

1695
        if (Session::modtools()) {
130✔
1696
            $sql .= "(SELECT COUNT(DISTINCT refmsgid) AS replycountoffer, userid FROM chat_messages INNER JOIN messages ON messages.id = chat_messages.refmsgid WHERE $userq AND chat_messages.date > '$start' AND refmsgid IS NOT NULL AND chat_messages.type = '" . ChatMessage::TYPE_INTERESTED . "' AND messages.type = '" . Message::TYPE_OFFER . "') t6 ON t6.userid = t0.id LEFT JOIN ";
130✔
1697
            $sql .= "(SELECT COUNT(DISTINCT refmsgid) AS replycountwanted, userid FROM chat_messages INNER JOIN messages ON messages.id = chat_messages.refmsgid WHERE $userq AND chat_messages.date > '$start' AND refmsgid IS NOT NULL AND chat_messages.type = '" . ChatMessage::TYPE_INTERESTED . "' AND messages.type = '" . Message::TYPE_WANTED . "') t7 ON t7.userid = t0.id LEFT JOIN ";
130✔
1698
        }
1699

1700
        $sql .= "(SELECT COUNT(DISTINCT(messages_reneged.msgid)) AS reneged, userid FROM messages_reneged WHERE $userq AND timestamp > ?) t3 ON t3.userid = t0.id LEFT JOIN
130✔
1701
(SELECT COUNT(DISTINCT messages_by.msgid) AS collected, messages_by.userid FROM messages_by INNER JOIN messages ON messages.id = messages_by.msgid INNER JOIN chat_messages ON chat_messages.refmsgid = messages.id AND messages.type = ? AND chat_messages.type = ? INNER JOIN messages_groups ON messages_groups.msgid = messages.id WHERE chat_messages.$userq AND messages_by.$userq AND messages_by.userid != messages.fromuser AND messages_groups.arrival >= '$days90') t4 ON t4.userid = t0.id LEFT JOIN
130✔
1702
(SELECT timestamp AS abouttime, text AS abouttext, userid FROM users_aboutme WHERE $userq ORDER BY timestamp DESC LIMIT 1) t5 ON t5.userid = t0.id
130✔
1703
;";
130✔
1704
        $counts = $this->dbhr->preQuery($sql, [
130✔
1705
            $start,
130✔
1706
            ChatMessage::TYPE_INTERESTED,
130✔
1707
            $start,
130✔
1708
            Message::TYPE_OFFER,
130✔
1709
            ChatMessage::TYPE_INTERESTED
130✔
1710
        ]);
130✔
1711

1712
        foreach ($users as $uid => $user) {
130✔
1713
            foreach ($counts as $count) {
130✔
1714
                if ($count['theuserid'] == $users[$uid]['id']) {
130✔
1715
                    $users[$uid]['info']['replies'] = $count['replycount'] ? $count['replycount'] : 0;
130✔
1716

1717
                    if (Session::modtools()) {
130✔
1718
                        $users[$uid]['info']['repliesoffer'] = $count['replycountoffer'] ? $count['replycountoffer'] : 0;
130✔
1719
                        $users[$uid]['info']['replieswanted'] = $count['replycountwanted'] ? $count['replycountwanted'] : 0;
130✔
1720
                    }
1721

1722
                    $users[$uid]['info']['reneged'] = $count['reneged'] ? $count['reneged'] : 0;
130✔
1723
                    $users[$uid]['info']['collected'] = $count['collected'] ? $count['collected'] : 0;
130✔
1724
                    $users[$uid]['info']['lastaccess'] = $count['lastaccess'] ? Utils::ISODate($count['lastaccess']) : NULL;
130✔
1725
                    $users[$uid]['info']['count'] = $count;
130✔
1726

1727
                    if (Utils::pres('abouttime', $count)) {
130✔
1728
                        $users[$uid]['info']['aboutme'] = [
2✔
1729
                            'timestamp' => Utils::ISODate($count['abouttime']),
2✔
1730
                            'text' => $count['abouttext']
2✔
1731
                        ];
2✔
1732
                    }
1733
                }
1734
            }
1735
        }
1736

1737
        $sql = "SELECT messages.fromuser AS userid, COUNT(*) AS count, messages.type, messages_outcomes.outcome FROM messages LEFT JOIN messages_outcomes ON messages_outcomes.msgid = messages.id INNER JOIN messages_groups ON messages_groups.msgid = messages.id WHERE fromuser IN (" . implode(',', $uids) . ") AND messages.arrival > ? AND collection = ? AND messages_groups.deleted = 0 GROUP BY messages.fromuser, messages.type, messages_outcomes.outcome;";
130✔
1738
        $counts = $this->dbhr->preQuery($sql, [
130✔
1739
            $start,
130✔
1740
            MessageCollection::APPROVED
130✔
1741
        ]);
130✔
1742

1743
        foreach ($users as $uid => $user) {
130✔
1744
            $users[$uid]['info']['offers'] = 0;
130✔
1745
            $users[$uid]['info']['wanteds'] = 0;
130✔
1746
            $users[$uid]['info']['openoffers'] = 0;
130✔
1747
            $users[$uid]['info']['openwanteds'] = 0;
130✔
1748
            $users[$uid]['info']['expectedreply'] = 0;
130✔
1749

1750
            foreach ($counts as $count) {
130✔
1751
                if ($count['userid'] == $users[$uid]['id']) {
64✔
1752
                    if ($count['type'] == Message::TYPE_OFFER) {
64✔
1753
                        $users[$uid]['info']['offers'] += $count['count'];
48✔
1754

1755
                        if (!Utils::pres('outcome', $count)) {
48✔
1756
                            $users[$uid]['info']['openoffers'] += $count['count'];
48✔
1757
                        }
1758
                    } else if ($count['type'] == Message::TYPE_WANTED) {
19✔
1759
                        $users[$uid]['info']['wanteds'] += $count['count'];
8✔
1760

1761
                        if (!Utils::pres('outcome', $count)) {
8✔
1762
                            $users[$uid]['info']['openwanteds'] += $count['count'];
4✔
1763
                        }
1764
                    }
1765
                }
1766
            }
1767
        }
1768

1769
        # Distance away.
1770
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
130✔
1771

1772
        if ($me) {
130✔
1773
            list ($mylat, $mylng, $myloc) = $me->getLatLng();
84✔
1774

1775
            if (!is_null($myloc)) {
84✔
1776
                $latlngs = $this->getLatLngs($users, FALSE, TRUE);
14✔
1777

1778
                foreach ($latlngs as $userid => $latlng) {
14✔
1779
                    if ($latlng) {
14✔
1780
                        $users[$userid]['info']['milesaway'] = $this->getDistanceBetween($mylat, $mylng, $latlng['lat'], $latlng['lng']);
13✔
1781
                    }
1782
                }
1783
            }
1784

1785
            $this->getPublicLocations($users);
84✔
1786
        }
1787

1788
        $r = new ChatRoom($this->dbhr, $this->dbhm);
130✔
1789
        $replytimes = $r->replyTimes($uids);
130✔
1790

1791
        foreach ($replytimes as $uid => $replytime) {
130✔
1792
            $users[$uid]['info']['replytime'] = $replytime;
130✔
1793
        }
1794

1795
        $nudges = $r->nudgeCounts($uids);
130✔
1796

1797
        foreach ($nudges as $uid => $nudgecount) {
130✔
1798
            $users[$uid]['info']['nudges'] = $nudgecount;
130✔
1799
        }
1800

1801
        $ratings = $this->getRatings($uids);
130✔
1802

1803
        foreach ($ratings as $uid => $rating) {
130✔
1804
            $users[$uid]['info']['ratings'] = $rating;
130✔
1805
        }
1806

1807
        $replies = $this->getExpectedReplies($uids, ChatRoom::ACTIVELIM, $grace);
130✔
1808

1809
        foreach ($replies as $reply) {
130✔
1810
            if ($reply['expectee']) {
130✔
1811
                $users[$reply['expectee']]['info']['expectedreply'] = $reply['count'];
1✔
1812
            }
1813
        }
1814
    }
1815
    
1816
    public function getInfo($grace = ChatRoom::REPLY_GRACE)
1817
    {
1818
        $users = [
14✔
1819
            $this->id => [
14✔
1820
                'id' => $this->id
14✔
1821
            ]
14✔
1822
        ];
14✔
1823

1824
        $this->getInfos($users, $grace);
14✔
1825

1826
        return ($users[$this->id]['info']);
14✔
1827
    }
1828

1829
    public function getAboutMe() {
1830
        $ret = NULL;
29✔
1831

1832
        $aboutmes = $this->dbhr->preQuery("SELECT * FROM users_aboutme WHERE userid = ? ORDER BY timestamp DESC LIMIT 1;", [
29✔
1833
            $this->id
29✔
1834
        ]);
29✔
1835

1836
        foreach ($aboutmes as $aboutme) {
29✔
1837
            $ret = [
1✔
1838
                'timestamp' => Utils::ISODate($aboutme['timestamp']),
1✔
1839
                'text' => $aboutme['text']
1✔
1840
            ];
1✔
1841
        }
1842

1843
        return($ret);
29✔
1844
    }
1845

1846
    private function md5_hex_to_dec($hex_str)
1847
    {
1848
        $arr = str_split($hex_str, 4);
20✔
1849
        foreach ($arr as $grp) {
20✔
1850
            $dec[] = str_pad(hexdec($grp), 5, '0', STR_PAD_LEFT);
20✔
1851
        }
1852
        return floatval("0." . implode('', $dec));
20✔
1853
    }
1854

1855
    public function getDistance($mylat, $mylng) {
1856
        list ($tlat, $tlng, $tloc) = $this->getLatLng();
1✔
1857
        #error_log("Get distance $mylat, $mylng, $tlat, $tlng = " . $this->getDistanceBetween($mylat, $mylng, $tlat, $tlng));
1858
        return($this->getDistanceBetween($mylat, $mylng, $tlat, $tlng));
1✔
1859
    }
1860

1861
    public function getDistanceBetween($mylat, $mylng, $tlat, $tlng)
1862
    {
1863
        $p1 = new POI($mylat, $mylng);
20✔
1864

1865
        # We need to make sure that we don't reveal the actual location (well, the postcode location) to
1866
        # someone attempting to triangulate.  So first we move the location a bit based on something which
1867
        # can't be known about a user - a hash of their ID and the password salt.
1868
        $tlat += ($this->md5_hex_to_dec(md5(PASSWORD_SALT . $this->id)) - 0.5) / 100;
20✔
1869
        $tlng += ($this->md5_hex_to_dec(md5($this->id . PASSWORD_SALT)) - 0.5) / 100;
20✔
1870

1871
        # Now randomise the distance a bit each time we get it, so that anyone attempting repeated measurements
1872
        # will get conflicting results around the precise location that isn't actually theirs.  But still close
1873
        # enough to be useful for our purposes.
1874
        $tlat += mt_rand(-100, 100) / 20000;
20✔
1875
        $tlng += mt_rand(-100, 100) / 20000;
20✔
1876

1877
        $p2 = new POI($tlat, $tlng);
20✔
1878
        $metres = $p1->getDistanceInMetersTo($p2);
20✔
1879
        $miles = $metres / 1609.344;
20✔
1880
        $miles = $miles > 2 ? round($miles) : round($miles, 1);
20✔
1881
        return ($miles);
20✔
1882
    }
1883

1884
    public function gravatar($email, $s = 80, $d = 'mm', $r = 'g')
1885
    {
1886
        $url = 'https://www.gravatar.com/avatar/';
20✔
1887
        $url .= md5(strtolower(trim($email)));
20✔
1888
        $url .= "?s=$s&d=$d&r=$r";
20✔
1889
        return $url;
20✔
1890
    }
1891

1892
    public function getPublicLocation()
1893
    {
1894
        $users = [
29✔
1895
            $this->id => [
29✔
1896
                'id' => $this->id
29✔
1897
            ]
29✔
1898
        ];
29✔
1899

1900
        $this->getLatLngs($users);
29✔
1901
        $this->getPublicLocations($users);
29✔
1902

1903
        return($users[$this->id]['info']['publiclocation']);
29✔
1904
    }
1905

1906
    public function ensureAvatar(&$atts)
1907
    {
1908
        # This involves querying external sites, so we need to use it with care, otherwise we can hang our
1909
        # system.  It can also cause updates, so if we call it lots of times, it can result in cluster issues.
1910
        $forcedefault = FALSE;
19✔
1911
        $settings = Utils::presdef('settings', $atts, NULL);
19✔
1912

1913
        if ($settings) {
19✔
1914
            if (array_key_exists('useprofile', $settings) && !$settings['useprofile']) {
19✔
1915
                $forcedefault = TRUE;
1✔
1916
            }
1917
        }
1918

1919
        if (!$forcedefault && $atts['profile']['default']) {
19✔
1920
            # See if we can do better than a default.
1921
            $emails = $this->getEmails($atts['id']);
19✔
1922

1923
            try {
1924
                foreach ($emails as $email) {
19✔
1925
                    if (preg_match('/(.*)-g.*@user.trashnothing.com/', $email['email'], $matches)) {
8✔
1926
                        # TrashNothing has an API we can use.
1927
                        $url = "https://trashnothing.com/api/users/{$matches[1]}/profile-image?default=" . urlencode('https://' . IMAGE_DOMAIN . '/defaultprofile.png');
1✔
1928
                        $atts['profile'] = [
1✔
1929
                            'url' => $url,
1✔
1930
                            'turl' => $url,
1✔
1931
                            'default' => FALSE,
1✔
1932
                            'TN' => TRUE
1✔
1933
                        ];
1✔
1934

1935
                        break;
1✔
1936
                    } else if (!Mail::ourDomain($email['email'])) {
8✔
1937
                        # Try for gravatar
1938
                        $gurl = $this->gravatar($email['email'], 200, 404);
8✔
1939
                        $g = @file_get_contents($gurl);
8✔
1940

1941
                        if ($g) {
8✔
1942
                            $atts['profile'] = [
1✔
1943
                                'url' => $gurl,
1✔
1944
                                'turl' => $this->gravatar($email['email'], 100, 404),
1✔
1945
                                'default' => FALSE,
1✔
1946
                                'gravatar' => TRUE
1✔
1947
                            ];
1✔
1948

1949
                            break;
1✔
1950
                        }
1951
                    }
1952
                }
1953

1954
                if ($atts['profile']['default']) {
19✔
1955
                    # Try for Facebook.
1956
                    $logins = $this->getLogins(TRUE);
19✔
1957
                    foreach ($logins as $login) {
19✔
1958
                        if ($login['type'] == User::LOGIN_FACEBOOK) {
10✔
1959
                            if (Utils::presdef('useprofile', $atts['settings'], TRUE)) {
×
1960
                                // As of October 2020 we can no longer just access the profile picture via the UID, we need to make a
1961
                                // call to the Graph API to fetch it.
1962
                                $f = new Facebook($this->dbhr, $this->dbhm);
×
1963
                                $atts['profile'] = $f->getProfilePicture($login['uid']);
×
1964
                            }
1965
                        }
1966
                    }
1967
                }
1968
            } catch (Throwable $e) {}
×
1969

1970
            $hash = NULL;
19✔
1971

1972
            if (!Utils::pres('default', $atts['profile'])) {
19✔
1973
                # We think we have a profile.  Make sure we can fetch it and filter out other people's
1974
                # default images.  But skip this check for trusted external sources (TN, gravatar) since
1975
                # they may not be accessible in test environments and we trust them anyway.
1976
                if (!Utils::pres('TN', $atts['profile']) && !Utils::pres('gravatar', $atts['profile'])) {
1✔
1977
                    $atts['profile']['default'] = TRUE;
×
1978
                    $this->filterDefault($atts['profile'], $hash);
×
1979
                }
1980
            }
1981

1982
            if (Utils::pres('default', $atts['profile'])) {
19✔
1983
                # Nothing - so get gravatar to generate a default for us.
1984
                $atts['profile'] = [
19✔
1985
                    'url' => $this->gravatar($this->getEmailPreferred(), 200, 'identicon'),
19✔
1986
                    'turl' => $this->gravatar($this->getEmailPreferred(), 100, 'identicon'),
19✔
1987
                    'default' => FALSE,
19✔
1988
                    'gravatar' => TRUE
19✔
1989
                ];
19✔
1990
            }
1991

1992
            # Save for next time.
1993
            $this->dbhm->preExec("INSERT INTO users_images (userid, url, `default`, hash) VALUES (?, ?, ?, ?);", [
19✔
1994
                $atts['id'],
19✔
1995
                $atts['profile']['default'] ? NULL : $atts['profile']['url'],
19✔
1996
                $atts['profile']['default'],
19✔
1997
                $hash
19✔
1998
            ]);
19✔
1999
        }
2000
    }
2001

2002
    public function filterDefault(&$profile, &$hash) {
2003
        $hasher = new ImageHash;
×
2004
        $data = Utils::pres('url', $profile) && strlen($profile['url']) ? @file_get_contents($profile['url']) : NULL;
×
2005
        $hash = NULL;
×
2006

2007
        if ($data) {
×
2008
            $img = @imagecreatefromstring($data);
×
2009

2010
            if ($img) {
×
2011
                $hash = $hasher->hash($img);
×
2012
                $profile['default'] = FALSE;
×
2013
            }
2014
        }
2015

2016
        if ($hash == 'e070716060607120' || $hash == 'd0f0323171707030' || $hash == '13130f4e0e0e4e52' ||
×
2017
            $hash == '1f0fcf9f9f9fcfff' || $hash == '23230f0c0e0e0c24' || $hash == 'c0c0e070e0603100' ||
×
2018
            $hash == 'f0f0316870f07130' || $hash == '242e070e060b0d24' || $hash == '69aa49558e4da88e') {
×
2019
            # This is a default profile - replace it with ours.
2020
            $profile['url'] = 'https://' . IMAGE_DOMAIN . '/defaultprofile.png';
×
2021
            $profile['turl'] = 'https://' . IMAGE_DOMAIN . '/defaultprofile.png';
×
2022
            $profile['default'] = TRUE;
×
2023
            $hash = NULL;
×
2024
        }
2025
    }
2026

2027
    public function getPublic($groupids = NULL, $history = TRUE, $comments = TRUE, $memberof = TRUE, $applied = TRUE, $modmailsonly = FALSE, $emailhistory = FALSE, $msgcoll = [ MessageCollection::APPROVED ], $historyfull = FALSE)
2028
    {
2029
        $atts = [];
232✔
2030

2031
        if ($this->id) {
232✔
2032
            $users = [ $this->user ];
232✔
2033
            $rets = $this->getPublics($users, $groupids, $history, $comments, $memberof, $applied, $modmailsonly, $emailhistory, $msgcoll, $historyfull);
232✔
2034
            $atts = $rets[$this->id];
232✔
2035
        }
2036

2037
        return($atts);
232✔
2038
    }
2039

2040
    public function getPublicAtts(&$rets, $users, $me) {
2041
        foreach ($users as &$user) {
237✔
2042
            if (!array_key_exists($user['id'], $rets)) {
237✔
2043
                $rets[$user['id']] = [];
237✔
2044
            }
2045

2046
            $atts = $this->publicatts;
237✔
2047

2048
            if (Session::modtools()) {
237✔
2049
                # We have some extra attributes.
2050
                $atts[] = 'deleted';
236✔
2051
                $atts[] = 'lastaccess';
236✔
2052
                $atts[] = 'trustlevel';
236✔
2053
            }
2054

2055
            foreach ($atts as $att) {
237✔
2056
                $rets[$user['id']][$att] = Utils::presdef($att, $user, NULL);
237✔
2057
            }
2058

2059
            $rets[$user['id']]['settings'] = ['dummy' => TRUE];
237✔
2060

2061
            if (Utils::presdef('settings', $user, NULL)) {
237✔
2062
                # This is a bit of a type guddle.
2063
                if (gettype($user['settings']) == 'string') {
30✔
2064
                    $rets[$user['id']]['settings'] = json_decode($user['settings'], TRUE);
29✔
2065
                } else {
2066
                    $rets[$user['id']]['settings'] = $user['settings'];
1✔
2067
                }
2068
            }
2069

2070
            if (Utils::pres('mylocation', $rets[$user['id']]['settings']) && Utils::pres('groupsnear', $rets[$user['id']]['settings']['mylocation'])) {
237✔
2071
                # This is large - no need for it.
2072
                $rets[$user['id']]['settings']['mylocation']['groupsnear'] = NULL;
×
2073
            }
2074

2075
            $rets[$user['id']]['settings']['notificationmails'] = array_key_exists('notificationmails', $rets[$user['id']]['settings']) ? $rets[$user['id']]['settings']['notificationmails'] : TRUE;
237✔
2076
            $rets[$user['id']]['settings']['engagement'] = array_key_exists('engagement', $rets[$user['id']]['settings']) ? $rets[$user['id']]['settings']['engagement'] : TRUE;
237✔
2077
            $rets[$user['id']]['settings']['modnotifs'] = array_key_exists('modnotifs', $rets[$user['id']]['settings']) ? $rets[$user['id']]['settings']['modnotifs'] : 4;
237✔
2078
            $rets[$user['id']]['settings']['backupmodnotifs'] = array_key_exists('backupmodnotifs', $rets[$user['id']]['settings']) ? $rets[$user['id']]['settings']['backupmodnotifs'] : 12;
237✔
2079

2080
            $rets[$user['id']]['displayname'] = $this->getName(TRUE, $user);
237✔
2081

2082
            $rets[$user['id']]['added'] = array_key_exists('added', $user) ? Utils::ISODate($user['added']) : NULL;
237✔
2083

2084
            foreach (['fullname', 'firstname', 'lastname'] as $att) {
237✔
2085
                # Make sure we don't return an email if somehow one has snuck in.
2086
                if (isset($rets[$user['id']][$att]) && $rets[$user['id']][$att] !== NULL) {
237✔
2087
                    $value = $rets[$user['id']][$att];
237✔
2088
                    $rets[$user['id']][$att] = $value && strpos($value, '@') !== FALSE ? substr($value, 0, strpos($value, '@')) : $value;
237✔
2089
                }
2090
            }
2091

2092
            if ($me && $rets[$user['id']]['id'] == $me->getId()) {
237✔
2093
                # Add in private attributes for our own entry.
2094
                $rets[$user['id']]['emails'] = $me->getEmails();
143✔
2095
                $rets[$user['id']]['email'] = $me->getEmailPreferred();
143✔
2096
                $rets[$user['id']]['relevantallowed'] = $me->getPrivate('relevantallowed');
143✔
2097
                $rets[$user['id']]['permissions'] = $me->getPrivate('permissions');
143✔
2098
            }
2099

2100
            if ($me && ($me->isModerator() || $user['id'] == $me->getId())) {
237✔
2101
                # Mods can see email settings, no matter which group.
2102
                $rets[$user['id']]['onholidaytill'] = (Utils::pres('onholidaytill', $rets[$user['id']]) && (time() < strtotime($rets[$user['id']]['onholidaytill']))) ? Utils::ISODate($rets[$user['id']]['onholidaytill']) : NULL;
163✔
2103
            } else {
2104
                # Don't show some attributes unless they're a mod or ourselves.
2105
                $ismod = $rets[$user['id']]['systemrole'] == User::SYSTEMROLE_ADMIN ||
117✔
2106
                    $rets[$user['id']]['systemrole'] == User::SYSTEMROLE_SUPPORT ||
117✔
2107
                    $rets[$user['id']]['systemrole'] == User::SYSTEMROLE_MODERATOR;
117✔
2108
                $showmod = $ismod && Utils::presdef('showmod', $rets[$user['id']]['settings'], FALSE);
117✔
2109
                $rets[$user['id']]['settings']['showmod'] = $showmod;
117✔
2110
                $rets[$user['id']]['yahooid'] = NULL;
117✔
2111
            }
2112

2113
            if (Utils::pres('deleted', $rets[$user['id']])) {
237✔
2114
                $rets[$user['id']]['deleted'] = Utils::ISODate($rets[$user['id']]['deleted']);
×
2115
            }
2116

2117
            if (Utils::pres('lastaccess', $rets[$user['id']])) {
237✔
2118
                $rets[$user['id']]['lastaccess'] = Utils::ISODate($rets[$user['id']]['lastaccess']);
236✔
2119
            }
2120
        }
2121
    }
2122
    
2123
    public function getPublicProfiles(&$rets, $users) {
2124
        $idsleft = [];
238✔
2125

2126
        foreach ($rets as $userid => $ret) {
238✔
2127
            if (Utils::pres($userid, $users)) {
238✔
2128
                if (Utils::pres('profile', $users[$userid])) {
8✔
2129
                    $rets[$userid]['profile'] = $users[$userid]['profile'];
1✔
2130
                } else {
2131
                    $idsleft[] = $userid;
8✔
2132
                }
2133
            } else {
2134
                $idsleft[] = $userid;
233✔
2135
            }
2136
        }
2137

2138
        if (count($idsleft)) {
238✔
2139
            foreach ($idsleft as $id) {
238✔
2140
                $rets[$id]['profile'] = [
238✔
2141
                    'url' => 'https://' . IMAGE_DOMAIN . '/defaultprofile.png',
238✔
2142
                    'turl' => 'https://' . IMAGE_DOMAIN . '/defaultprofile.png',
238✔
2143
                    'default' => TRUE
238✔
2144
                ];
238✔
2145
            }
2146

2147
            # Ordering by id ASC means we'll end up with the most recent value in our output.
2148
            $sql = "SELECT * FROM users_images WHERE userid IN (" . implode(',', $idsleft) . ") ORDER BY userid, id ASC;";
238✔
2149

2150
            $profiles = $this->dbhr->preQuery($sql, NULL, FALSE, FALSE);
238✔
2151

2152
            foreach ($profiles as $profile) {
238✔
2153
                # Get a profile.  This function is called so frequently that we can't afford to query external sites
2154
                # within it, so if we don't find one, we default to none.
2155
                if (Utils::pres('settings', $rets[$profile['userid']]) &&
12✔
2156
                    gettype($rets[$profile['userid']]['settings']) == 'array' &&
12✔
2157
                    (!array_key_exists('useprofile', $rets[$profile['userid']]['settings']) || $rets[$profile['userid']]['settings']['useprofile'])) {
12✔
2158
                    # We found a profile that we can use.
2159
                    if (!$profile['default']) {
12✔
2160
                        # If it's a gravatar image we can return a thumbnail url that specifies a different size.
2161
                        $turl = Utils::pres('url', $profile) ? $profile['url'] : ('https://' . IMAGE_DOMAIN . "/tuimg_{$profile['id']}.jpg");
12✔
2162
                        $turl = strpos($turl, 'https://www.gravatar.com') === 0 ? str_replace('?s=200', '?s=100', $turl) : $turl;
12✔
2163
                        $rets[$profile['userid']]['profile'] = [
12✔
2164
                            'id' => $profile['id'],
12✔
2165
                            'url' => Utils::pres('url', $profile) ? $profile['url'] : ('https://' . IMAGE_DOMAIN . "/uimg_{$profile['id']}.jpg"),
12✔
2166
                            'turl' => $turl,
12✔
2167
                            'default' => FALSE
12✔
2168
                        ];
12✔
2169
                    }
2170
                }
2171
            }
2172
        }
2173
    }
2174

2175
    public function getPublicHistory($me, &$rets, $users, $historyfull, $systemrole, $msgcoll = [ MessageCollection::APPROVED ]) {
2176
        $idsleft = [];
117✔
2177

2178
        foreach ($rets as $userid => $ret) {
117✔
2179
            if (Utils::pres($userid, $users)) {
117✔
2180
                if (array_key_exists('messagehistory', $users[$userid])) {
6✔
2181
                    $rets[$userid]['messagehistory'] = $users[$userid]['messagehistory'];
1✔
2182
                    $rets[$userid]['modmails'] = $users[$userid]['modmails'];
1✔
2183
                } else {
2184
                    $idsleft[] = $userid;
6✔
2185
                }
2186
            } else {
2187
                $idsleft[] = $userid;
112✔
2188
            }
2189
        }
2190

2191
        if (count($idsleft)) {
117✔
2192
            foreach ($rets as &$atts) {
117✔
2193
                $atts['messagehistory'] = [];
117✔
2194
            }
2195

2196
            # Add in the message history - from any of the emails associated with this user.
2197
            #
2198
            # We want one entry in here for each repost, so we LEFT JOIN with the reposts table.
2199
            $sql = null;
117✔
2200

2201
            if (count($idsleft)) {
117✔
2202
                $collq = " AND messages_groups.collection IN ('" . implode("','", $msgcoll) . "') ";
117✔
2203
                $earliest = $historyfull ? '1970-01-01' : date('Y-m-d', strtotime("midnight 30 days ago"));
117✔
2204
                $delq = $historyfull ? '' : ' AND messages_groups.deleted = 0';
117✔
2205

2206
                $sql = "SELECT GREATEST(COALESCE(messages_postings.date, messages.arrival), COALESCE(messages_postings.date, messages.arrival)) AS postdate, (SELECT outcome FROM messages_outcomes WHERE messages_outcomes.msgid = messages.id ORDER BY timestamp DESC LIMIT 1) AS outcome, messages.fromuser, messages.id, messages.fromaddr, messages.arrival, messages.date, messages_groups.collection, messages_groups.deleted, messages_postings.date AS repostdate, messages_postings.repost, messages_postings.autorepost, messages.subject, messages.type, DATEDIFF(NOW(), messages.date) AS daysago, messages_groups.groupid FROM messages INNER JOIN messages_groups ON messages.id = messages_groups.msgid $collq AND fromuser IN (" . implode(
117✔
2207
                        ',',
117✔
2208
                        $idsleft
117✔
2209
                    ) . ") $delq LEFT JOIN messages_postings ON messages.id = messages_postings.msgid HAVING postdate > ? ORDER BY postdate DESC;";
117✔
2210
            }
2211

2212
            if ($sql) {
117✔
2213
                $histories = $this->dbhr->preQuery(
117✔
2214
                    $sql,
117✔
2215
                    [
117✔
2216
                        $earliest
117✔
2217
                    ]
117✔
2218
                );
117✔
2219

2220
                foreach ($rets as $userid => $ret) {
117✔
2221
                    foreach ($histories as $history) {
117✔
2222
                        if ($history['fromuser'] == $ret['id']) {
34✔
2223
                            $history['arrival'] = Utils::pres('repostdate', $history) ? Utils::ISODate(
34✔
2224
                                $history['repostdate']
34✔
2225
                            ) : Utils::ISODate($history['arrival']);
34✔
2226
                            $history['date'] = Utils::ISODate($history['date']);
34✔
2227
                            $rets[$userid]['messagehistory'][] = $history;
34✔
2228
                        }
2229
                    }
2230
                }
2231
            }
2232

2233
            # Add in a count of recent "modmail" type logs which a mod might care about.
2234
            $modships = $me ? $me->getModeratorships() : [];
117✔
2235
            $modships = count($modships) == 0 ? [0] : $modships;
117✔
2236
            $sql = "SELECT COUNT(*) AS count, userid FROM `users_modmails` WHERE userid IN (" . implode(
117✔
2237
                    ',',
117✔
2238
                    $idsleft
117✔
2239
                ) . ") AND groupid IN (" . implode(',', $modships) . ") GROUP BY userid;";
117✔
2240
            $modmails = $this->dbhr->preQuery($sql, null, FALSE, FALSE);
117✔
2241

2242
            foreach ($idsleft as $userid) {
117✔
2243
                $rets[$userid]['modmails'] = 0;
117✔
2244
            }
2245

2246
            foreach ($rets as $userid => $ret) {
117✔
2247
                foreach ($modmails as $modmail) {
117✔
2248
                    if ($modmail['userid'] == $ret['id']) {
1✔
2249
                        $rets[$userid]['modmails'] = $modmail['count'] ? $modmail['count'] : 0;
1✔
2250
                    }
2251
                }
2252
            }
2253
        }
2254
    }
2255

2256
    public function getPublicMemberOf(&$rets, $me, $freeglemod, $memberof, $systemrole) {
2257
        $userids = [];
236✔
2258

2259
        foreach ($rets as $ret) {
236✔
2260
            $ret['activearea'] = NULL;
236✔
2261

2262
            if (!Utils::pres('memberof', $ret)) {
236✔
2263
                # We haven't provided the complete list already, e.g. because the user is suspect.
2264
                $userids[] = $ret['id'];
236✔
2265
            }
2266
        }
2267

2268
        if ($memberof &&
236✔
2269
            count($userids) &&
236✔
2270
            ($systemrole == User::ROLE_MODERATOR || $systemrole == User::SYSTEMROLE_ADMIN || $systemrole == User::SYSTEMROLE_SUPPORT)
236✔
2271
        ) {
2272
            # Gt the recent ones (which preserves some privacy for the user but allows us to spot abuse) and any which
2273
            # are on our groups.
2274
            $addmax = ($systemrole == User::SYSTEMROLE_ADMIN || $systemrole == User::SYSTEMROLE_SUPPORT) ? PHP_INT_MAX : 31;
78✔
2275
            $modids = array_merge([0], $me->getModeratorships());
78✔
2276
            $freegleq = $freeglemod ? " OR groups.type = 'Freegle' " : '';
78✔
2277
            $sql = "SELECT DISTINCT memberships.*, memberships.collection AS coll, groups.onhere, groups.nameshort, groups.namefull, groups.lat, groups.lng, groups.type FROM memberships INNER JOIN `groups` ON memberships.groupid = groups.id WHERE userid IN (" . implode(',', $userids) . ") AND (DATEDIFF(NOW(), memberships.added) <= $addmax OR memberships.groupid IN (" . implode(',', $modids) . ") $freegleq);";
78✔
2278
            $groups = $this->dbhr->preQuery($sql, NULL, FALSE, FALSE);
78✔
2279
            #error_log("Get groups $sql, {$this->id}");
2280

2281
            foreach ($rets as &$ret) {
78✔
2282
                $ret['memberof'] = [];
78✔
2283
                $ourEmailId = NULL;
78✔
2284

2285
                if (Utils::pres('emails', $ret)) {
78✔
2286
                    foreach ($ret['emails'] as $email) {
64✔
2287
                        if (Mail::ourDomain($email['email'])) {
64✔
2288
                            $ourEmailId = $email['id'];
7✔
2289
                        }
2290
                    }
2291
                }
2292

2293
                foreach ($groups as $group) {
78✔
2294
                    if ($ret['id'] ==  $group['userid']) {
65✔
2295
                        $name = $group['namefull'] ? $group['namefull'] : $group['nameshort'];
65✔
2296
                        $added = Utils::ISODate(Utils::pres('yadded', $group) ? $group['yadded'] : $group['added']);
65✔
2297
                        $addedago = floor((time() - strtotime($added)) / 86400);
65✔
2298

2299
                        $ret['memberof'][] = [
65✔
2300
                            'id' => $group['groupid'],
65✔
2301
                            'membershipid' => $group['id'],
65✔
2302
                            'namedisplay' => $name,
65✔
2303
                            'nameshort' => $group['nameshort'],
65✔
2304
                            'added' => $added,
65✔
2305
                            'collection' => $group['coll'],
65✔
2306
                            'role' => $group['role'],
65✔
2307
                            'emailfrequency' => $group['emailfrequency'],
65✔
2308
                            'eventsallowed' => $group['eventsallowed'],
65✔
2309
                            'volunteeringallowed' => $group['volunteeringallowed'],
65✔
2310
                            'ourpostingstatus' => $group['ourPostingStatus'],
65✔
2311
                            'type' => $group['type'],
65✔
2312
                            'onhere' => $group['onhere'],
65✔
2313
                            'reviewrequestedat' => $group['reviewrequestedat'] ? Utils::ISODate($group['reviewrequestedat']) : NULL,
65✔
2314
                            'reviewreason' => $group['reviewreason'],
65✔
2315
                            'reviewedat' => $group['reviewedat'] ? Utils::ISODate($group['reviewedat']) : NULL,
65✔
2316
                            'heldby' => $group['heldby'],
65✔
2317
                        ];
65✔
2318

2319

2320
                        // Counts as active if recently joined.
2321
                        if ($group['lat'] && $group['lng'] && $addedago <= 31) {
65✔
2322
                            $box = Utils::presdef('activearea', $ret, NULL);
2✔
2323

2324
                            $ret['activearea'] = [
2✔
2325
                                'swlat' => is_null($box)? $group['lat'] : min($group['lat'], $box['swlat']),
2✔
2326
                                'swlng' => is_null($box)? $group['lng'] : min($group['lng'], $box['swlng']),
2✔
2327
                                'nelng' => is_null($box)? $group['lng'] : max($group['lng'], $box['nelng']),
2✔
2328
                                'nelat' => is_null($box)? $group['lat'] : max($group['lat'], $box['nelat'])
2✔
2329
                            ];
2✔
2330
                        }
2331
                    }
2332
                }
2333
            }
2334
        }
2335
    }
2336

2337
    public function getPublicApplied(&$rets, $users, $applied, $systemrole) {
2338
        if ($applied &&
236✔
2339
            $systemrole == User::ROLE_MODERATOR ||
236✔
2340
            $systemrole == User::SYSTEMROLE_ADMIN ||
236✔
2341
            $systemrole == User::SYSTEMROLE_SUPPORT
236✔
2342
        ) {
2343
            $idsleft = [];
79✔
2344

2345
            foreach ($rets as $userid => $ret) {
79✔
2346
                if (Utils::pres($userid, $users)) {
79✔
2347
                    if (array_key_exists('applied', $users[$userid])) {
2✔
2348
                        $rets[$userid]['applied'] = $users[$userid]['applied'];
1✔
2349
                        $rets[$userid]['activedistance'] = $users[$userid]['activedistance'];
1✔
2350
                    } else {
2351
                        $idsleft[] = $userid;
2✔
2352
                    }
2353
                } else {
2354
                    $idsleft[] = $userid;
79✔
2355
                }
2356
            }
2357

2358
            if (count($idsleft)) {
79✔
2359
                # As well as being a member of a group, they might have joined and left, or applied and been rejected.
2360
                # This is useful info for moderators.
2361
                $sql = "SELECT DISTINCT memberships_history.*, groups.nameshort, groups.namefull, groups.lat, groups.lng FROM memberships_history INNER JOIN `groups` ON memberships_history.groupid = groups.id WHERE userid IN (" . implode(
79✔
2362
                        ',',
79✔
2363
                        $idsleft
79✔
2364
                    ) . ") AND DATEDIFF(NOW(), added) <= 31 AND groups.publish = 1 AND groups.onmap = 1 ORDER BY added DESC;";
79✔
2365
                $membs = $this->dbhr->preQuery($sql);
79✔
2366

2367
                foreach ($rets as &$ret) {
79✔
2368
                    $ret['applied'] = [];
79✔
2369
                    $ret['activedistance'] = null;
79✔
2370

2371
                    foreach ($membs as $memb) {
79✔
2372
                        if ($ret['id'] == $memb['userid']) {
67✔
2373
                            $name = $memb['namefull'] ? $memb['namefull'] : $memb['nameshort'];
67✔
2374
                            $memb['namedisplay'] = $name;
67✔
2375
                            $memb['added'] = Utils::ISODate($memb['added']);
67✔
2376
                            $memb['id'] = $memb['groupid'];
67✔
2377
                            unset($memb['groupid']);
67✔
2378

2379
                            if ($memb['lat'] && $memb['lng']) {
67✔
2380
                                $box = Utils::presdef('activearea', $ret, null);
2✔
2381

2382
                                $box = [
2✔
2383
                                    'swlat' => is_null($box)? $memb['lat'] : min($memb['lat'], $box['swlat']),
2✔
2384
                                    'swlng' => is_null($box)? $memb['lng'] : min($memb['lng'], $box['swlng']),
2✔
2385
                                    'nelng' => is_null($box)? $memb['lng'] : max($memb['lng'], $box['nelng']),
2✔
2386
                                    'nelat' => is_null($box)? $memb['lat'] : max($memb['lat'], $box['nelat'])
2✔
2387
                                ];
2✔
2388

2389
                                $ret['activearea'] = $box;
2✔
2390

2391
                                if ($box) {
2✔
2392
                                    $ret['activedistance'] = round(
2✔
2393
                                        Location::getDistance(
2✔
2394
                                            $box['swlat'],
2✔
2395
                                            $box['swlng'],
2✔
2396
                                            $box['nelat'],
2✔
2397
                                            $box['nelng']
2✔
2398
                                        )
2✔
2399
                                    );
2✔
2400
                                }
2401
                            }
2402

2403
                            $ret['applied'][] = $memb;
67✔
2404
                        }
2405
                    }
2406
                }
2407
            }
2408
        }
2409
    }
2410

2411
    public function getPublicSpammer(&$rets, $me, $systemrole) {
2412
        # We want to check for spammers.  If we have suitable rights then we can
2413
        # return detailed info; otherwise just that they are on the list.
2414
        #
2415
        # We don't do this for our own logged in user, otherwise we recurse to death.
2416
        $myid = $me ? $me->getId() : NULL;
236✔
2417
        $userids = array_filter(array_keys($rets), function($val) use ($myid) {
236✔
2418
            return($val != $myid);
236✔
2419
        });
236✔
2420

2421
        if (count($userids)) {
236✔
2422
            # Fetch the users.  There are so many users that there is no point trying to use the query cache.
2423
            $sql = "SELECT * FROM spam_users WHERE userid IN (" . implode(',', $userids) . ");";
157✔
2424

2425
            $users = $this->dbhr->preQuery($sql, NULL, FALSE, FALSE);
157✔
2426

2427
            foreach ($rets as &$ret) {
157✔
2428
                foreach ($users as &$user) {
157✔
2429
                    if ($user['userid'] == $ret['id']) {
3✔
2430
                        if (Session::modtools() && ($systemrole == User::ROLE_MODERATOR ||
3✔
2431
                                $systemrole == User::SYSTEMROLE_ADMIN ||
3✔
2432
                                $systemrole == User::SYSTEMROLE_SUPPORT)) {
3✔
2433
                            $ret['spammer'] = [];
2✔
2434
                            foreach (['id', 'userid', 'byuserid', 'added', 'collection', 'reason'] as $att) {
2✔
2435
                                $ret['spammer'][$att]= $user[$att];
2✔
2436
                            }
2437

2438
                            $ret['spammer']['added'] = Utils::ISODate($ret['spammer']['added']);
2✔
2439
                        } else if ($user['collection'] == Spam::TYPE_SPAMMER) {
2✔
2440
                            # Only return to members that they are a spammer once approved.
2441
                            $ret['spammer'] = TRUE;
2✔
2442
                        }
2443
                    }
2444
                }
2445
            }
2446
        }
2447
    }
2448

2449
    public function getEmailHistory(&$rets) {
2450
        $userids = array_keys($rets);
5✔
2451

2452
        $emails = $this->dbhr->preQuery("SELECT * FROM logs_emails WHERE userid IN (" . implode(',', $userids) . ");", NULL, FALSE, FALSE);
5✔
2453

2454
        foreach ($rets as $retind => $ret) {
5✔
2455
            $rets[$retind]['emailhistory'] = [];
5✔
2456

2457
            foreach ($emails as $email) {
5✔
2458
                if ($rets[$retind]['id'] == $email['userid']) {
1✔
2459
                    $email['timestamp'] = Utils::ISODate($email['timestamp']);
1✔
2460
                    unset($email['userid']);
1✔
2461
                    $rets[$retind]['emailhistory'][] = $email;
1✔
2462
                }
2463
            }
2464
        }
2465
    }
2466

2467
    public function getPublicsById($uids, $groupids = NULL, $history = TRUE, $comments = TRUE, $memberof = TRUE, $applied = TRUE, $modmailsonly = FALSE, $emailhistory = FALSE, $msgcoll = [MessageCollection::APPROVED], $historyfull = FALSE) {
2468
        $rets = [];
141✔
2469

2470
        # We might have some of these in cache, especially ourselves.
2471
        $uidsleft = [];
141✔
2472

2473
        foreach ($uids as $uid) {
141✔
2474
            $u = User::get($this->dbhr, $this->dbhm, $uid, TRUE, TRUE);
139✔
2475

2476
            if ($u) {
139✔
2477
                $rets[$uid] = $u->getPublic($groupids, $history, $comments, $memberof, $applied, $modmailsonly, $emailhistory, $msgcoll, $historyfull);
134✔
2478
            } else {
2479
                $uidsleft[] = $uid;
8✔
2480
            }
2481
        }
2482

2483
        $uidsleft = array_filter($uidsleft);
141✔
2484

2485
        if (count($uidsleft)) {
141✔
2486
            $us = $this->dbhr->preQuery("SELECT * FROM users WHERE id IN (" . implode(',', $uidsleft) . ");", NULL, FALSE, FALSE);
7✔
2487
            $users = [];
7✔
2488
            foreach ($us as $u) {
7✔
2489
                $users[$u['id']] = $u;
7✔
2490
            }
2491

2492
            if (count($users)) {
7✔
2493
                $users = $this->getPublics($users, $groupids, $history, $comments, $memberof, $applied, $modmailsonly, $emailhistory, $msgcoll, $historyfull);
7✔
2494

2495
                foreach ($users as $user) {
7✔
2496
                    $rets[$user['id']] = $user;
7✔
2497
                }
2498
            }
2499
        }
2500

2501
        return($rets);
141✔
2502
    }
2503

2504
    public function isTN() {
2505
        return strpos($this->getEmailPreferred(), '@user.trashnothing.com') !== FALSE;
49✔
2506
    }
2507

2508
    public function isLJ() {
2509
        return $this->user['ljuserid'] !== NULL;
×
2510
    }
2511

2512
    public function getPublicEmails(&$rets) {
2513
        $userids = array_keys($rets);
95✔
2514
        $emails = $this->getEmailsById($userids);
95✔
2515

2516
        foreach ($rets as &$ret) {
95✔
2517
            if (Utils::pres($ret['id'], $emails)) {
94✔
2518
                $ret['emails'] = $emails[$ret['id']];
78✔
2519
            }
2520
        }
2521
    }
2522

2523
    public static function purgedUser($id) {
2524
        return [
1✔
2525
            'id' => $id,
1✔
2526
            'displayname' => 'Purged user #' . $id,
1✔
2527
            'systemrole' => User::SYSTEMROLE_USER
1✔
2528
        ];
1✔
2529
    }
2530

2531
    public function getPublicLogs($me, &$rets, $modmailsonly, &$ctx, $suppress = TRUE, $seeall = FALSE) {
2532
        # Add in the log entries we have for this user.  We exclude some logs of little interest to mods.
2533
        # - creation - either of ourselves or others during syncing.
2534
        # - deletion of users due to syncing
2535
        # Don't cache as there might be a lot, they're rarely used, and it can cause UT issues.
2536
        $myid = $me ? $me->getId() : NULL;
18✔
2537
        $uids = array_keys($rets);
18✔
2538
        $startq = $ctx ? (" AND id < " . intval($ctx['id']) . " ") : '';
18✔
2539
        $modships = $me ? $me->getModeratorships() : [];
18✔
2540
        $groupq = count($modships) ? (" AND groupid IN (" . implode(',', $modships) . ")") : '';
18✔
2541
        $modmailq = " AND ((type = 'Message' AND subtype IN ('Rejected', 'Deleted', 'Replied')) OR (type = 'User' AND subtype IN ('Mailed', 'Rejected', 'Deleted'))) $groupq";
18✔
2542
        $modq = $modmailsonly ? $modmailq : '';
18✔
2543
        $suppq = $suppress ? " AND NOT (type = 'User' AND subtype IN('Created', 'Merged', 'YahooConfirmed')) " : '';
18✔
2544
        $sql = "SELECT DISTINCT * FROM logs WHERE (user IN (" . implode(',', $uids) . ") OR byuser IN (" . implode(',', $uids) . ")) $startq $suppq $modq ORDER BY id DESC LIMIT 50;";
18✔
2545
        $logs = $this->dbhr->preQuery($sql, NULL, FALSE, FALSE);
18✔
2546
        $groups = [];
18✔
2547
        $users = [];
18✔
2548
        $configs = [];
18✔
2549

2550
        # Get all log users in a single go.
2551
        $loguids = array_filter(array_merge(array_column($rets, 'user'), array_column($rets, 'byuser')));
18✔
2552

2553
        if (count($loguids)) {
18✔
2554
            $u = new User($this->dbhr, $this->dbhm);
×
2555
            $users = $u->getPublicsById($loguids, NULL, FALSE, FALSE, FALSE, FALSE);
×
2556
        }
2557

2558
        if (!$ctx) {
18✔
2559
            $ctx = ['id' => 0];
18✔
2560
        }
2561

2562
        foreach ($rets as $uid => $ret) {
18✔
2563
            $rets[$uid]['logs'] = [];
18✔
2564

2565
            foreach ($logs as $log) {
18✔
2566
                if ($log['user'] == $ret['id'] || $log['byuser'] == $ret['id']) {
17✔
2567
                    $ctx['id'] = $ctx['id'] == 0 ? $log['id'] : intval(min($ctx['id'], $log['id']));
17✔
2568

2569
                    if (Utils::pres('byuser', $log)) {
17✔
2570
                        if (!Utils::pres($log['byuser'], $users)) {
13✔
2571
                            $u = User::get($this->dbhr, $this->dbhm, $log['byuser']);
10✔
2572

2573
                            if ($u->getId() == $log['byuser']) {
10✔
2574
                                $users[$log['byuser']] = $u->getPublic(NULL, FALSE);
10✔
2575
                            } else {
2576
                                $users[$log['byuser']] = User::purgedUser($log['byuser']);
×
2577
                            }
2578
                        }
2579

2580
                        $log['byuser'] = $users[$log['byuser']];
13✔
2581
                    }
2582

2583
                    if (Utils::pres('user', $log)) {
17✔
2584
                        if (!Utils::pres($log['user'], $users)) {
16✔
2585
                            $u = User::get($this->dbhr, $this->dbhm, $log['user']);
14✔
2586

2587
                            if ($u->getId() == $log['user']) {
14✔
2588
                                $users[$log['user']] = $u->getPublic(NULL, FALSE);
14✔
2589
                            } else {
2590
                                $users[$log['user']] = User::purgedUser($log['user']);
×
2591
                            }
2592
                        }
2593

2594
                        $log['user'] = $users[$log['user']];
16✔
2595
                    }
2596

2597
                    if (Utils::pres('groupid', $log)) {
17✔
2598
                        if (!Utils::pres($log['groupid'], $groups)) {
13✔
2599
                            $g = Group::get($this->dbhr, $this->dbhm, $log['groupid']);
13✔
2600

2601
                            if ($g->getId()) {
13✔
2602
                                $groups[$log['groupid']] = $g->getPublic();
13✔
2603
                                $groups[$log['groupid']]['myrole'] = $me ? $me->getRoleForGroup($log['groupid']) : User::ROLE_NONMEMBER;
13✔
2604
                            }
2605
                        }
2606

2607
                        # We can see logs for ourselves.
2608
                        if (!($myid != NULL && Utils::pres('user', $log) && Utils::presdef('id', $log['user'], NULL) == $myid) &&
13✔
2609
                            $g->getId() &&
13✔
2610
                            $groups[$log['groupid']]['myrole'] != User::ROLE_OWNER &&
13✔
2611
                            $groups[$log['groupid']]['myrole'] != User::ROLE_MODERATOR &&
13✔
2612
                            !$seeall
13✔
2613
                        ) {
2614
                            # We can only see logs for this group if we have a mod role, or if we have appropriate system
2615
                            # rights.  Skip this log.
2616
                            continue;
2✔
2617
                        }
2618

2619
                        $log['group'] = Utils::presdef($log['groupid'], $groups, NULL);
13✔
2620
                    }
2621

2622
                    if (Utils::pres('configid', $log)) {
17✔
2623
                        if (!Utils::pres($log['configid'], $configs)) {
2✔
2624
                            $c = new ModConfig($this->dbhr, $this->dbhm, $log['configid']);
2✔
2625

2626
                            if ($c->getId()) {
2✔
2627
                                $configs[$log['configid']] = $c->getPublic();
2✔
2628
                            }
2629
                        }
2630

2631
                        if (Utils::pres($log['configid'], $configs)) {
2✔
2632
                            $log['config'] = $configs[$log['configid']];
2✔
2633
                        }
2634
                    }
2635

2636
                    if (Utils::pres('stdmsgid', $log)) {
17✔
2637
                        $s = new StdMessage($this->dbhr, $this->dbhm, $log['stdmsgid']);
2✔
2638
                        $log['stdmsg'] = $s->getPublic();
2✔
2639
                    }
2640

2641
                    if (Utils::pres('msgid', $log)) {
17✔
2642
                        $m = new Message($this->dbhr, $this->dbhm, $log['msgid']);
8✔
2643

2644
                        if ($m->getID()) {
8✔
2645
                            $log['message'] = $m->getPublic(FALSE);
8✔
2646

2647
                            # If we're a mod (which we must be because we're accessing logs) we need to see the
2648
                            # envelopeto, because this is displayed in MT.  No real privacy issues in that.
2649
                            $log['message']['envelopeto'] = $m->getPrivate('envelopeto');
8✔
2650
                        } else {
2651
                            # The message has been deleted.
2652
                            $log['message'] = [
1✔
2653
                                'id' => $log['msgid'],
1✔
2654
                                'deleted' => TRUE
1✔
2655
                            ];
1✔
2656

2657
                            # See if we can find out why.
2658
                            $sql = "SELECT * FROM logs WHERE msgid = ? AND type = 'Message' AND subtype = 'Deleted' ORDER BY id DESC LIMIT 1;";
1✔
2659
                            $deletelogs = $this->dbhr->preQuery($sql, [$log['msgid']]);
1✔
2660
                            foreach ($deletelogs as $deletelog) {
1✔
2661
                                $log['message']['deletereason'] = $deletelog['text'];
1✔
2662
                            }
2663
                        }
2664

2665
                        # Prune large attributes.
2666
                        unset($log['message']['textbody']);
8✔
2667
                        unset($log['message']['htmlbody']);
8✔
2668
                        unset($log['message']['message']);
8✔
2669
                    }
2670

2671
                    $log['timestamp'] = Utils::ISODate($log['timestamp']);
17✔
2672

2673
                    $rets[$uid]['logs'][] = $log;
17✔
2674
                }
2675
            }
2676
        }
2677

2678
        # Get merge history
2679
        $merges = [];
18✔
2680
        do {
2681
            $added = FALSE;
18✔
2682
            $sql = "SELECT * FROM logs WHERE type = 'User' AND subtype = 'Merged' AND user IN (" . implode(',', $uids) . ");";
18✔
2683
            $logs = $this->dbhr->preQuery($sql);
18✔
2684
            foreach ($logs as $log) {
18✔
2685
                #error_log("Consider merge log {$log['text']}");
2686
                if (preg_match('/Merged (.*) into (.*?) \((.*)\)/', $log['text'], $matches)) {
1✔
2687
                    #error_log("Matched " . var_export($matches, TRUE));
2688
                    #error_log("Check ids {$matches[1]} and {$matches[2]}");
2689
                    foreach ([$matches[1], $matches[2]] as $id) {
1✔
2690
                        if (!in_array($id, $uids, TRUE)) {
1✔
2691
                            $added = TRUE;
1✔
2692
                            $uids[] = $id;
1✔
2693
                            $merges[] = ['timestamp' => Utils::ISODate($log['timestamp']), 'from' => $matches[1], 'to' => $matches[2], 'reason' => $matches[3]];
1✔
2694
                        }
2695
                    }
2696
                }
2697
            }
2698
        } while ($added);
18✔
2699

2700
        $merges = array_unique($merges, SORT_REGULAR);
18✔
2701

2702
        foreach ($rets as $uid => $ret) {
18✔
2703
            $rets[$uid]['merges'] = [];
18✔
2704

2705
            foreach ($merges as $merge) {
18✔
2706
                if ($merge['from'] == $ret['id'] || $merge['to'] == $ret['id']) {
1✔
2707
                    $rets[$uid]['merges'][] = $merge;
1✔
2708
                }
2709
            }
2710
        }
2711
    }
2712

2713
    public function getPublics($users, $groupids = NULL, $history = TRUE, $comments = TRUE, $memberof = TRUE, $applied = TRUE, $modmailsonly = FALSE, $emailhistory = FALSE, $msgcoll = [MessageCollection::APPROVED], $historyfull = FALSE)
2714
    {
2715
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
237✔
2716
        $systemrole = $me ? $me->getPrivate('systemrole') : User::SYSTEMROLE_USER;
237✔
2717
        $freeglemod = $me && $me->isFreegleMod();
237✔
2718

2719
        $rets = [];
237✔
2720

2721
        $this->getPublicAtts($rets, $users, $me);
237✔
2722
        $this->getPublicProfiles($rets, $users);
237✔
2723
        $this->getSupporters($rets, $users);
237✔
2724

2725
        if ($systemrole == User::ROLE_MODERATOR || $systemrole == User::SYSTEMROLE_ADMIN || $systemrole == User::SYSTEMROLE_SUPPORT) {
237✔
2726
            $this->getPublicEmails($rets);
94✔
2727
        }
2728

2729
        if ($history) {
237✔
2730
            $this->getPublicHistory($me, $rets, $users, $historyfull, $systemrole, $msgcoll);
117✔
2731
        }
2732

2733
        if (Session::modtools()) {
237✔
2734
            $this->getPublicMemberOf($rets, $me, $freeglemod, $memberof, $systemrole);
236✔
2735
            $this->getPublicApplied($rets, $users, $applied, $systemrole);
236✔
2736
            $this->getPublicSpammer($rets, $me, $systemrole);
236✔
2737

2738
            if ($comments) {
236✔
2739
                $this->getComments($me, $rets);
194✔
2740
            }
2741

2742
            if ($emailhistory) {
236✔
2743
                $this->getEmailHistory($rets);
5✔
2744
            }
2745
        }
2746

2747
        return ($rets);
237✔
2748
    }
2749

2750
    public function isAdmin()
2751
    {
2752
        return ($this->user['systemrole'] == User::SYSTEMROLE_ADMIN);
12✔
2753
    }
2754

2755
    public function isAdminOrSupport()
2756
    {
2757
        $ret = ($this->user['systemrole'] == User::SYSTEMROLE_ADMIN || $this->user['systemrole'] == User::SYSTEMROLE_SUPPORT);
97✔
2758

2759
        # Use array_key_exists so that old sessions which predate this fix can continue.  Prevents shock of the new.
2760
        if ($ret && array_key_exists('supportAllowed', $_SESSION) && !$_SESSION['supportAllowed']) {
97✔
2761
            // TODO Disabled for now.
2762
//            $ret = FALSE;
2763
        }
2764

2765
        return $ret;
97✔
2766
    }
2767

2768
    public function isModerator()
2769
    {
2770
        return ($this->user['systemrole'] == User::SYSTEMROLE_ADMIN ||
242✔
2771
            $this->user['systemrole'] == User::SYSTEMROLE_SUPPORT ||
242✔
2772
            $this->user['systemrole'] == User::SYSTEMROLE_MODERATOR);
242✔
2773
    }
2774

2775
    public function systemRoleMax($role1, $role2)
2776
    {
2777
        $role = User::SYSTEMROLE_USER;
20✔
2778

2779
        if ($role1 == User::SYSTEMROLE_MODERATOR || $role2 == User::SYSTEMROLE_MODERATOR) {
20✔
2780
            $role = User::SYSTEMROLE_MODERATOR;
7✔
2781
        }
2782

2783
        if ($role1 == User::SYSTEMROLE_SUPPORT || $role2 == User::SYSTEMROLE_SUPPORT) {
20✔
2784
            $role = User::SYSTEMROLE_SUPPORT;
3✔
2785
        }
2786

2787
        if ($role1 == User::SYSTEMROLE_ADMIN || $role2 == User::SYSTEMROLE_ADMIN) {
20✔
2788
            $role = User::SYSTEMROLE_ADMIN;
2✔
2789
        }
2790

2791
        return ($role);
20✔
2792
    }
2793

2794
    public function roleMax($role1, $role2)
2795
    {
2796
        $role = User::ROLE_NONMEMBER;
20✔
2797

2798
        if ($role1 == User::ROLE_MEMBER || $role2 == User::ROLE_MEMBER) {
20✔
2799
            $role = User::ROLE_MEMBER;
17✔
2800
        }
2801

2802
        if ($role1 == User::ROLE_MODERATOR || $role2 == User::ROLE_MODERATOR) {
20✔
2803
            $role = User::ROLE_MODERATOR;
10✔
2804
        }
2805

2806
        if ($role1 == User::ROLE_OWNER || $role2 == User::ROLE_OWNER) {
20✔
2807
            $role = User::ROLE_OWNER;
3✔
2808
        }
2809

2810
        return ($role);
20✔
2811
    }
2812

2813
    public function roleMin($role1, $role2)
2814
    {
2815
        $role = User::ROLE_OWNER;
12✔
2816

2817
        if ($role1 == User::ROLE_MODERATOR || $role2 == User::ROLE_MODERATOR) {
12✔
2818
            $role = User::ROLE_MODERATOR;
8✔
2819
        }
2820

2821
        if ($role1 == User::ROLE_MEMBER || $role2 == User::ROLE_MEMBER) {
12✔
2822
            $role = User::ROLE_MEMBER;
9✔
2823
        }
2824

2825
        if ($role1 == User::ROLE_NONMEMBER || $role2 == User::ROLE_NONMEMBER) {
12✔
2826
            $role = User::ROLE_NONMEMBER;
3✔
2827
        }
2828

2829
        return ($role);
12✔
2830
    }
2831

2832
    public function merge($id1, $id2, $reason, $forcemerge = FALSE)
2833
    {
2834
        error_log("Merge $id2 into $id1, $reason");
16✔
2835

2836
        # We might not be able to merge them, if one or the other has the setting to prevent that.
2837
        $u1 = User::get($this->dbhr, $this->dbhm, $id1);
16✔
2838
        $u2 = User::get($this->dbhr, $this->dbhm, $id2);
16✔
2839
        $ret = FALSE;
16✔
2840

2841
        if ($id1 != $id2 && (($u1->canMerge() && $u2->canMerge()) || ($forcemerge))) {
16✔
2842
            #
2843
            # We want to merge two users.  At present we just merge the memberships, comments, emails and logs; we don't try to
2844
            # merge any conflicting settings.
2845
            #
2846
            # Both users might have membership of the same group, including at different levels.
2847
            #
2848
            # A useful query to find foreign key references is of this form:
2849
            #
2850
            # USE information_schema; SELECT * FROM KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = 'iznik' AND REFERENCED_TABLE_NAME = 'users';
2851
            #
2852
            # We avoid too much use of quoting in preQuery/preExec because quoted numbers can't use a numeric index and therefore
2853
            # perform slowly.
2854
            #error_log("Merge $id2 into $id1");
2855
            $l = new Log($this->dbhr, $this->dbhm);
14✔
2856
            $me = Session::whoAmI($this->dbhr, $this->dbhm);
14✔
2857

2858
            $rc = $this->dbhm->beginTransaction();
14✔
2859
            $rollback = FALSE;
14✔
2860

2861
            if ($rc) {
14✔
2862
                try {
2863
                    #error_log("Started transaction");
2864
                    $rollback = TRUE;
14✔
2865

2866
                    # Merge the top-level memberships
2867
                    $id2membs = $this->dbhr->preQuery("SELECT * FROM memberships WHERE userid = $id2;");
14✔
2868
                    foreach ($id2membs as $id2memb) {
14✔
2869
                        $rc2 = $rc;
8✔
2870
                        # Jiggery-pokery with $rc for UT purposes.
2871
                        #error_log("$id2 member of {$id2memb['groupid']} ");
2872
                        $id1membs = $this->dbhr->preQuery("SELECT * FROM memberships WHERE userid = $id1 AND groupid = {$id2memb['groupid']};");
8✔
2873

2874
                        if (count($id1membs) == 0) {
8✔
2875
                            # id1 is not already a member.  Just change our id2 membership to id1.
2876
                            #error_log("...$id1 not a member, UPDATE");
2877
                            $rc2 = $this->dbhm->preExec("UPDATE IGNORE memberships SET userid = $id1 WHERE userid = $id2 AND groupid = {$id2memb['groupid']};");
3✔
2878

2879
                            #error_log("Membership UPDATE merge returned $rc2");
2880
                        } else {
2881
                            # id1 is already a member, so we really have to merge.
2882
                            #
2883
                            # Our new membership has the highest role.
2884
                            $id1memb = $id1membs[0];
7✔
2885
                            $role = User::roleMax($id1memb['role'], $id2memb['role']);
7✔
2886
                            #error_log("...as is $id1, roles {$id1memb['role']} vs {$id2memb['role']} => $role");
2887

2888
                            if ($role != $id1memb['role']) {
7✔
2889
                                $rc2 = $this->dbhm->preExec("UPDATE memberships SET role = ? WHERE userid = $id1 AND groupid = {$id2memb['groupid']};", [
2✔
2890
                                    $role
2✔
2891
                                ]);
2✔
2892
                                #error_log("Set role $rc2");
2893
                            }
2894

2895
                            if ($rc2) {
7✔
2896
                                #  Our added date should be the older of the two.
2897
                                $date = min(strtotime($id1memb['added']), strtotime($id2memb['added']));
7✔
2898
                                $mysqltime = date("Y-m-d H:i:s", $date);
7✔
2899
                                $rc2 = $this->dbhm->preExec("UPDATE memberships SET added = ? WHERE userid = $id1 AND groupid = {$id2memb['groupid']};", [
7✔
2900
                                    $mysqltime
7✔
2901
                                ]);
7✔
2902
                                #error_log("Added $rc2");
2903
                            }
2904

2905
                            # There are several attributes we want to take the non-NULL version.
2906
                            foreach (['configid', 'settings', 'heldby'] as $key) {
6✔
2907
                                #error_log("Check {$id2memb['groupid']} memb $id2 $key = " . Utils::presdef($key, $id2memb, NULL));
2908
                                if ($id2memb[$key]) {
6✔
2909
                                    if ($rc2) {
1✔
2910
                                        $rc2 = $this->dbhm->preExec("UPDATE memberships SET $key = ? WHERE userid = $id1 AND groupid = {$id2memb['groupid']};", [
1✔
2911
                                            $id2memb[$key]
1✔
2912
                                        ]);
1✔
2913
                                        #error_log("Set att $key = {$id2memb[$key]} $rc2");
2914
                                    }
2915
                                }
2916
                            }
2917
                        }
2918

2919
                        $rc = $rc2 && $rc ? $rc2 : 0;
7✔
2920
                    }
2921

2922
                    # Merge the emails.  Both might have a primary address; if so then id1 wins.
2923
                    # There is a unique index, so there can't be a conflict on email.
2924
                    if ($rc) {
13✔
2925
                        $primary = NULL;
13✔
2926
                        $foundprim = FALSE;
13✔
2927
                        $sql = "SELECT * FROM users_emails WHERE userid = $id2 AND preferred = 1;";
13✔
2928
                        $emails = $this->dbhr->preQuery($sql);
13✔
2929
                        foreach ($emails as $email) {
13✔
2930
                            $primary = $email['id'];
7✔
2931
                            $foundprim = TRUE;
7✔
2932
                        }
2933

2934
                        $sql = "SELECT * FROM users_emails WHERE userid = $id1 AND preferred = 1;";
13✔
2935
                        $emails = $this->dbhr->preQuery($sql);
13✔
2936
                        foreach ($emails as $email) {
13✔
2937
                            $primary = $email['id'];
11✔
2938
                            $foundprim = TRUE;
11✔
2939
                        }
2940

2941
                        if (!$foundprim) {
13✔
2942
                            # No primary.  Whatever we would choose for id1 should become the new one.
2943
                            $pemail = $u1->getEmailPreferred();
1✔
2944
                            $emails = $this->dbhr->preQuery("SELECT * FROM users_emails WHERE email LIKE ?;", [
1✔
2945
                                $pemail
1✔
2946
                            ]);
1✔
2947

2948
                            foreach ($emails as $email) {
1✔
2949
                                $primary = $email['id'];
1✔
2950
                            }
2951
                        }
2952

2953
                        #error_log("Merge emails");
2954
                        $sql = "UPDATE users_emails SET userid = $id1, preferred = 0 WHERE userid = $id2;";
13✔
2955
                        $rc = $this->dbhm->preExec($sql);
13✔
2956

2957
                        if ($primary) {
13✔
2958
                            $sql = "UPDATE users_emails SET preferred = 1 WHERE id = $primary;";
13✔
2959
                            $rc = $this->dbhm->preExec($sql);
13✔
2960
                        }
2961

2962
                        #error_log("Emails now " . var_export($this->dbhm->preQuery("SELECT * FROM users_emails WHERE userid = $id1;"), TRUE));
2963
                        #error_log("Email merge returned $rc");
2964
                    }
2965

2966
                    if ($rc) {
13✔
2967
                        # Merge other foreign keys where success is less important.  For some of these there might already
2968
                        # be entries, so we do an IGNORE.
2969
                        $this->dbhm->preExec("UPDATE locations_excluded SET userid = $id1 WHERE userid = $id2;");
13✔
2970
                        $this->dbhm->preExec("UPDATE IGNORE chat_roster SET userid = $id1 WHERE userid = $id2;");
13✔
2971
                        $this->dbhm->preExec("UPDATE IGNORE sessions SET userid = $id1 WHERE userid = $id2;");
13✔
2972
                        $this->dbhm->preExec("UPDATE IGNORE spam_users SET userid = $id1 WHERE userid = $id2;");
13✔
2973
                        $this->dbhm->preExec("UPDATE IGNORE spam_users SET byuserid = $id1 WHERE byuserid = $id2;");
13✔
2974
                        $this->dbhm->preExec("UPDATE IGNORE users_addresses SET userid = $id1 WHERE userid = $id2;");
13✔
2975
                        $this->dbhm->preExec("UPDATE users_comments SET userid = $id1 WHERE userid = $id2;");
13✔
2976
                        $this->dbhm->preExec("UPDATE users_comments SET byuserid = $id1 WHERE byuserid = $id2;");
13✔
2977
                        $this->dbhm->preExec("UPDATE IGNORE users_donations SET userid = $id1 WHERE userid = $id2;");
13✔
2978
                        $this->dbhm->preExec("UPDATE IGNORE users_images SET userid = $id1 WHERE userid = $id2;");
13✔
2979
                        $this->dbhm->preExec("UPDATE IGNORE users_invitations SET userid = $id1 WHERE userid = $id2;");
13✔
2980
                        $this->dbhm->preExec("UPDATE users_logins SET userid = $id1 WHERE userid = $id2;");
13✔
2981
                        $this->dbhm->preExec("UPDATE IGNORE users_logins SET uid = $id1 WHERE userid = $id1 AND `type` = ?;", [
13✔
2982
                            User::LOGIN_NATIVE
13✔
2983
                        ]);
13✔
2984
                        $this->dbhm->preExec("UPDATE IGNORE users_nearby SET userid = $id1 WHERE userid = $id2;");
13✔
2985
                        $this->dbhm->preExec("UPDATE IGNORE users_notifications SET fromuser = $id1 WHERE fromuser = $id2;");
13✔
2986
                        $this->dbhm->preExec("UPDATE IGNORE users_notifications SET touser = $id1 WHERE touser = $id2;");
13✔
2987
                        $this->dbhm->preExec("UPDATE IGNORE users_nudges SET fromuser = $id1 WHERE fromuser = $id2;");
13✔
2988
                        $this->dbhm->preExec("UPDATE IGNORE users_nudges SET touser = $id1 WHERE touser = $id2;");
13✔
2989
                        $this->dbhm->preExec("UPDATE IGNORE users_push_notifications SET userid = $id1 WHERE userid = $id2;");
13✔
2990
                        $this->dbhm->preExec("UPDATE IGNORE users_requests SET userid = $id1 WHERE userid = $id2;");
13✔
2991
                        $this->dbhm->preExec("UPDATE IGNORE users_requests SET completedby = $id1 WHERE completedby = $id2;");
13✔
2992
                        $this->dbhm->preExec("UPDATE IGNORE users_searches SET userid = $id1 WHERE userid = $id2;");
13✔
2993
                        $this->dbhm->preExec("UPDATE IGNORE newsfeed SET userid = $id1 WHERE userid = $id2;");
13✔
2994
                        $this->dbhm->preExec("UPDATE IGNORE messages_reneged SET userid = $id1 WHERE userid = $id2;");
13✔
2995
                        $this->dbhm->preExec("UPDATE IGNORE users_stories SET userid = $id1 WHERE userid = $id2;");
13✔
2996
                        $this->dbhm->preExec("UPDATE IGNORE users_stories_likes SET userid = $id1 WHERE userid = $id2;");
13✔
2997
                        $this->dbhm->preExec("UPDATE IGNORE users_stories_requested SET userid = $id1 WHERE userid = $id2;");
13✔
2998
                        $this->dbhm->preExec("UPDATE IGNORE users_thanks SET userid = $id1 WHERE userid = $id2;");
13✔
2999
                        $this->dbhm->preExec("UPDATE IGNORE modnotifs SET userid = $id1 WHERE userid = $id2;");
13✔
3000
                        $this->dbhm->preExec("UPDATE IGNORE teams_members SET userid = $id1 WHERE userid = $id2;");
13✔
3001
                        $this->dbhm->preExec("UPDATE IGNORE users_aboutme SET userid = $id1 WHERE userid = $id2;");
13✔
3002
                        $this->dbhm->preExec("UPDATE IGNORE ratings SET rater = $id1 WHERE rater = $id2;");
13✔
3003
                        $this->dbhm->preExec("UPDATE IGNORE ratings SET ratee = $id1 WHERE ratee = $id2;");
13✔
3004
                        $this->dbhm->preExec("UPDATE IGNORE users_replytime SET userid = $id1 WHERE userid = $id2;");
13✔
3005
                        $this->dbhm->preExec("UPDATE IGNORE messages_promises SET userid = $id1 WHERE userid = $id2;");
13✔
3006
                        $this->dbhm->preExec("UPDATE IGNORE messages_by SET userid = $id1 WHERE userid = $id2;");
13✔
3007
                        $this->dbhm->preExec("UPDATE IGNORE trysts SET user1 = $id1 WHERE user1 = $id2;");
13✔
3008
                        $this->dbhm->preExec("UPDATE IGNORE trysts SET user2 = $id1 WHERE user2 = $id2;");
13✔
3009
                        $this->dbhm->preExec("UPDATE IGNORE isochrones_users SET userid = $id1 WHERE userid = $id2;");
13✔
3010
                        $this->dbhm->preExec("UPDATE IGNORE microactions SET userid = $id1 WHERE userid = $id2;");
13✔
3011

3012
                        # Handle the bans.
3013
                        $this->dbhm->preExec("UPDATE IGNORE users_banned SET userid = $id1 WHERE userid = $id2;");
13✔
3014
                        $this->dbhm->preExec("UPDATE IGNORE users_banned SET byuser = $id1 WHERE byuser = $id2;");
13✔
3015

3016

3017
                        $bans = $this->dbhm->preQuery("SELECT * FROM users_banned WHERE userid = $id1");
13✔
3018
                        foreach ($bans as $ban) {
13✔
3019
                            # Make sure we are not a member; this could happen if one of the users is banned and
3020
                            # the other is not.
3021
                            $this->dbhm->preExec("DELETE FROM memberships WHERE userid = ? AND groupid = ?", [
1✔
3022
                                $id1,
1✔
3023
                                $ban['groupid']
1✔
3024
                            ]);
1✔
3025
                        }
3026

3027
                        # Merge chat rooms.  There might have be two separate rooms already, which means that we need
3028
                        # to make sure that messages from both end up in the same one.
3029
                        $rooms = $this->dbhr->preQuery("SELECT * FROM chat_rooms WHERE (user1 = $id2 OR user2 = $id2) AND chattype IN (?,?);", [
13✔
3030
                            ChatRoom::TYPE_USER2MOD,
13✔
3031
                            ChatRoom::TYPE_USER2USER
13✔
3032
                        ]);
13✔
3033

3034
                        foreach ($rooms as $room) {
13✔
3035
                            # Now see if there is already a chat room between the destination user and whatever this
3036
                            # one is.
3037
                            switch ($room['chattype']) {
1✔
3038
                                case ChatRoom::TYPE_USER2MOD;
1✔
3039
                                    $sql = "SELECT id FROM chat_rooms WHERE user1 = $id1 AND groupid = {$room['groupid']};";
1✔
3040
                                    break;
1✔
3041
                                case ChatRoom::TYPE_USER2USER;
1✔
3042
                                    $other = $room['user1'] == $id2 ? $room['user2'] : $room['user1'];
1✔
3043
                                    $sql = "SELECT id FROM chat_rooms WHERE (user1 = $id1 AND user2 = $other) OR (user2 = $id1 AND user1 = $other);";
1✔
3044
                                    break;
1✔
3045
                            }
3046

3047
                            $alreadys = $this->dbhr->preQuery($sql);
1✔
3048
                            #error_log("Check room {$room['id']} {$room['user1']} => {$room['user2']} $sql " . count($alreadys));
3049

3050
                            if (count($alreadys) > 0) {
1✔
3051
                                # Yes, there already is one.
3052
                                $this->dbhm->preExec("UPDATE chat_messages SET chatid = {$alreadys[0]['id']} WHERE chatid = {$room['id']}");
1✔
3053

3054
                                # Make sure latestmessage is set correctly.
3055
                                $this->dbhm->preExec("UPDATE chat_rooms SET latestmessage = GREATEST(latestmessage, ?) WHERE id = ?", [
1✔
3056
                                    $room['latestmessage'],
1✔
3057
                                    $alreadys[0]['id']
1✔
3058
                                ]);
1✔
3059
                            } else {
3060
                                # No, there isn't, so we can update our old one.
3061
                                $sql = $room['user1'] == $id2 ? "UPDATE chat_rooms SET user1 = $id1 WHERE id = {$room['id']};" : "UPDATE chat_rooms SET user2 = $id1 WHERE id = {$room['id']};";
1✔
3062
                                $this->dbhm->preExec($sql);
1✔
3063
                            }
3064
                        }
3065

3066
                        $this->dbhm->preExec("UPDATE chat_messages SET userid = $id1 WHERE userid = $id2;");
13✔
3067
                    }
3068

3069
                    # Merge attributes we want to keep if we have them in id2 but not id1.  Some will have unique
3070
                    # keys, so update to delete them.
3071
                    foreach (['fullname', 'firstname', 'lastname', 'yahooid'] as $att) {
13✔
3072
                        $users = $this->dbhm->preQuery("SELECT $att FROM users WHERE id = $id2;");
13✔
3073
                        foreach ($users as $user) {
13✔
3074
                            $this->dbhm->preExec("UPDATE users SET $att = NULL WHERE id = $id2;");
13✔
3075
                            User::clearCache($id1);
13✔
3076
                            User::clearCache($id2);
13✔
3077

3078
                            if (!$u1->getPrivate($att)) {
13✔
3079
                                if ($att != 'fullname') {
13✔
3080
                                    $this->dbhm->preExec("UPDATE users SET $att = ? WHERE id = $id1 AND $att IS NULL;", [$user[$att]]);
13✔
3081
                                } else if (stripos($user[$att], 'fbuser') === FALSE && stripos($user[$att], '-owner') === FALSE) {
2✔
3082
                                    # We don't want to overwrite a name with FBUser or a -owner address.
3083
                                    $this->dbhm->preExec("UPDATE users SET $att = ? WHERE id = $id1;", [$user[$att]]);
2✔
3084
                                }
3085
                            }
3086
                        }
3087
                    }
3088

3089
                    # Merge the logs.  There should be logs both about and by each user, so we can use the rc to check success.
3090
                    if ($rc) {
13✔
3091
                        $rc = $this->dbhm->preExec("UPDATE logs SET user = $id1 WHERE user = $id2;");
13✔
3092

3093
                        #error_log("Log merge 1 returned $rc");
3094
                    }
3095

3096
                    if ($rc) {
13✔
3097
                        $rc = $this->dbhm->preExec("UPDATE logs SET byuser = $id1 WHERE byuser = $id2;");
13✔
3098

3099
                        #error_log("Log merge 2 returned $rc");
3100
                    }
3101

3102
                    # Merge the fromuser in messages.  There might not be any, and it's not the end of the world
3103
                    # if this info isn't correct, so ignore the rc.
3104
                    #error_log("Merge messages, current rc $rc");
3105
                    if ($rc) {
13✔
3106
                        $this->dbhm->preExec("UPDATE messages SET fromuser = $id1 WHERE fromuser = $id2;");
13✔
3107
                    }
3108

3109
                    # Merge the history
3110
                    #error_log("Merge history, current rc $rc");
3111
                    if ($rc) {
13✔
3112
                        $this->dbhm->preExec("UPDATE messages_history SET fromuser = $id1 WHERE fromuser = $id2;");
13✔
3113
                        $this->dbhm->preExec("UPDATE memberships_history SET userid = $id1 WHERE userid = $id2;");
13✔
3114
                    }
3115

3116
                    # Merge the systemrole.
3117
                    $u1s = $this->dbhr->preQuery("SELECT systemrole FROM users WHERE id = $id1;");
13✔
3118
                    foreach ($u1s as $u1) {
13✔
3119
                        $u2s = $this->dbhr->preQuery("SELECT systemrole FROM users WHERE id = $id2;");
13✔
3120
                        foreach ($u2s as $u2) {
13✔
3121
                            $rc = $this->dbhm->preExec("UPDATE users SET systemrole = ? WHERE id = $id1;", [
13✔
3122
                                $this->systemRoleMax($u1['systemrole'], $u2['systemrole'])
13✔
3123
                            ]);
13✔
3124
                        }
3125
                        User::clearCache($id1);
13✔
3126
                    }
3127

3128
                    # Merge the add date.
3129
                    $u1 = User::get($this->dbhr, $this->dbhm, $id1);
13✔
3130
                    $u2 = User::get($this->dbhr, $this->dbhm, $id2);
13✔
3131
                    $this->dbhm->preExec("UPDATE users SET added = ? WHERE id = $id1;", [
13✔
3132
                        strtotime($u1->getPrivate('added')) < strtotime($u2->getPrivate('added')) ? $u1->getPrivate('added') : $u2->getPrivate('added')
13✔
3133
                    ]);
13✔
3134

3135
                    $this->dbhm->preExec("UPDATE users SET lastupdated = NOW() WHERE id = ?;", [
13✔
3136
                        $id1
13✔
3137
                    ]);
13✔
3138

3139
                    $tnid1 = $u2->getPrivate('tnuserid');
13✔
3140
                    $tnid2 = $u2->getPrivate('tnuserid');
13✔
3141

3142
                    if (!$tnid1 && $tnid2) {
13✔
3143
                        $u2->setPrivate('tnuserid', NULL);
×
3144
                        $u1->setPrivate('tnuserid', $tnid2);
×
3145
                    }
3146

3147
                    # Merge any gift aid.  We currently only support one declaration per user so we want to take
3148
                    # the most favourable.
3149
                    $giftaids = $this->dbhr->preQuery("SELECT * FROM giftaid WHERE userid IN ($id1, $id2) ORDER BY id ASC;");
13✔
3150

3151
                    if (count($giftaids)) {
13✔
3152
                        $weights = [
1✔
3153
                            Donations::PERIOD_PAST_4_YEARS_AND_FUTURE => 0,
1✔
3154
                            Donations::PERIOD_SINCE => 1,
1✔
3155
                            Donations::PERIOD_FUTURE=> 2,
1✔
3156
                            Donations::PERIOD_THIS => 3,
1✔
3157
                            Donations::PERIOD_DECLINED => 4
1✔
3158
                        ];
1✔
3159

3160
                        $best = NULL;
1✔
3161
                        foreach ($giftaids as $giftaid) {
1✔
3162
                            if ($best == NULL ||
1✔
3163
                                $weights[$giftaid['period']] < $weights[$best['period']]) {
1✔
3164
                                $best = $giftaid;
1✔
3165
                            }
3166
                        }
3167

3168
                        foreach ($giftaids as $giftaid) {
1✔
3169
                            if ($giftaid['id'] != $best['id']) {
1✔
3170
                                $this->dbhm->preExec("DELETE FROM giftaid WHERE id = ?;", [
1✔
3171
                                    $giftaid['id']
1✔
3172
                                ]);
1✔
3173
                            }
3174
                        }
3175

3176
                        $this->dbhm->preExec("UPDATE giftaid SET userid = ? WHERE id = ?;", [
1✔
3177
                            $id1,
1✔
3178
                            $best['id']
1✔
3179
                        ]);
1✔
3180
                    }
3181

3182
                    if ($rc) {
13✔
3183
                        # Log the merge - before the delete otherwise we will fail to log it.
3184
                        $l->log([
13✔
3185
                            'type' => Log::TYPE_USER,
13✔
3186
                            'subtype' => Log::SUBTYPE_MERGED,
13✔
3187
                            'user' => $id2,
13✔
3188
                            'byuser' => $me ? $me->getId() : NULL,
13✔
3189
                            'text' => "Merged $id2 into $id1 ($reason)"
13✔
3190
                        ]);
13✔
3191

3192
                        # Log under both users to make sure we can trace it.
3193
                        $l->log([
13✔
3194
                            'type' => Log::TYPE_USER,
13✔
3195
                            'subtype' => Log::SUBTYPE_MERGED,
13✔
3196
                            'user' => $id1,
13✔
3197
                            'byuser' => $me ? $me->getId() : NULL,
13✔
3198
                            'text' => "Merged $id2 into $id1 ($reason)"
13✔
3199
                        ]);
13✔
3200
                    }
3201

3202
                    if ($rc) {
13✔
3203
                        # Everything worked.
3204
                        $rollback = FALSE;
13✔
3205

3206
                        # We might have merged ourself!
3207
                        if (Utils::pres('id', $_SESSION) == $id2) {
13✔
3208
                            $_SESSION['id'] = $id1;
13✔
3209
                        }
3210
                    }
3211
                } catch (\Exception $e) {
1✔
3212
                    error_log("Merge exception " . $e->getMessage());
1✔
3213
                    $rollback = TRUE;
1✔
3214
                }
3215
            }
3216

3217
            if ($rollback) {
14✔
3218
                # Something went wrong.
3219
                #error_log("Merge failed, rollback");
3220
                $this->dbhm->rollBack();
1✔
3221
                $ret = FALSE;
1✔
3222
            } else {
3223
                #error_log("Merge worked, commit");
3224
                $ret = $this->dbhm->commit();
13✔
3225

3226
                if ($ret) {
13✔
3227
                    # Finally, delete id2.  We used to this inside the transaction, but the result was that
3228
                    # fromuser sometimes got set to NULL on messages owned by id2, despite them having been set to
3229
                    # id1 earlier on.  Either we're dumb, or there's a subtle interaction between transactions,
3230
                    # foreign keys and Percona clusters.  This is safer and proves to be more reliable.
3231
                    #
3232
                    # Make sure we don't pick up an old cached version, as we've just changed it quite a bit.
3233
                    error_log("Merged $id1 < $id2, $reason");
13✔
3234
                    $deleteme = new User($this->dbhm, $this->dbhm, $id2);
13✔
3235
                    $rc = $deleteme->delete(NULL, NULL, NULL, FALSE);
13✔
3236
                }
3237
            }
3238
        }
3239

3240
        return ($ret);
16✔
3241
    }
3242

3243
    public function mailer($user, $modmail, $toname, $to, $bcc, $fromname, $from, $subject, $text) {
3244
        try {
3245
            #error_log(session_id() . " mail " . microtime(TRUE));
3246

3247
            list ($transport, $mailer) = Mail::getMailer();
4✔
3248

3249
            $message = \Swift_Message::newInstance()
4✔
3250
                ->setSubject($subject)
4✔
3251
                ->setFrom([$from => $fromname])
4✔
3252
                ->setTo([$to => $toname])
4✔
3253
                ->setBody($text);
4✔
3254

3255
            # We add some headers so that if we receive this back, we can identify it as a mod mail.
3256
            $headers = $message->getHeaders();
4✔
3257

3258
            if ($user) {
4✔
3259
                $headers->addTextHeader('X-Iznik-From-User', $user->getId());
4✔
3260
            }
3261

3262
            $headers->addTextHeader('X-Iznik-ModMail', $modmail);
4✔
3263

3264
            if ($bcc) {
4✔
3265
                $message->setBcc(explode(',', $bcc));
1✔
3266
            }
3267

3268
            Mail::addHeaders($this->dbhr, $this->dbhm, $message,Mail::MODMAIL, $user->getId());
4✔
3269

3270
            $this->sendIt($mailer, $message);
4✔
3271

3272
            # Stop the transport, otherwise the message doesn't get sent until the UT script finishes.
3273
            $transport->stop();
4✔
3274

3275
            #error_log(session_id() . " mailed " . microtime(TRUE));
3276
        } catch (\Exception $e) {
4✔
3277
            # Not much we can do - shouldn't really happen given the failover transport.
3278
            // @codeCoverageIgnoreStart
3279
            error_log("Send failed with " . $e->getMessage());
3280
            // @codeCoverageIgnoreEnd
3281
        }
3282
    }
3283

3284
    private function maybeMail($groupid, $subject, $body, $action)
3285
    {
3286
        if ($body) {
4✔
3287
            # We have a mail to send.
3288
            $me = Session::whoAmI($this->dbhr, $this->dbhm);
4✔
3289
            $myid = $me->getId();
4✔
3290

3291
            $g = Group::get($this->dbhr, $this->dbhm, $groupid);
4✔
3292
            $atts = $g->getPublic();
4✔
3293

3294
            $me = Session::whoAmI($this->dbhr, $this->dbhm);
4✔
3295

3296
            # Find who to send it from.  If we have a config to use for this group then it will tell us.
3297
            $name = $me->getName();
4✔
3298
            $c = new ModConfig($this->dbhr, $this->dbhm);
4✔
3299
            $cid = $c->getForGroup($me->getId(), $groupid);
4✔
3300
            $c = new ModConfig($this->dbhr, $this->dbhm, $cid);
4✔
3301
            $fromname = $c->getPrivate('fromname');
4✔
3302
            $name = ($fromname == 'Groupname Moderator') ? '$groupname Moderator' : $name;
4✔
3303

3304
            # We can do a simple substitution in the from name.
3305
            $name = str_replace('$groupname', $atts['namedisplay'], $name);
4✔
3306

3307
            $bcc = $c->getBcc($action);
4✔
3308

3309
            if ($bcc) {
4✔
3310
                $bcc = str_replace('$groupname', $atts['nameshort'], $bcc);
1✔
3311
            }
3312

3313
            # We add the message into chat.
3314
            $r = new ChatRoom($this->dbhr, $this->dbhm);
4✔
3315
            $rid = $r->createUser2Mod($this->id, $groupid);
4✔
3316
            $m = NULL;
4✔
3317

3318
            $to = $this->getEmailPreferred();
4✔
3319

3320
            if ($rid) {
4✔
3321
                # Create the message.  Mark it as needing review to prevent timing window.
3322
                $m = new ChatMessage($this->dbhr, $this->dbhm);
4✔
3323
                list ($mid, $banned) = $m->create($rid,
4✔
3324
                    $myid,
4✔
3325
                    "$subject\r\n\r\n$body",
4✔
3326
                    ChatMessage::TYPE_MODMAIL,
4✔
3327
                    NULL,
4✔
3328
                    TRUE,
4✔
3329
                    NULL,
4✔
3330
                    NULL,
4✔
3331
                    NULL,
4✔
3332
                    NULL,
4✔
3333
                    NULL,
4✔
3334
                    TRUE,
4✔
3335
                    TRUE);
4✔
3336

3337
                $this->mailer($me, TRUE, $this->getName(), $bcc, NULL, $name, $g->getModsEmail(), $subject, "(This is a BCC of a message sent to Freegle user #" . $this->id . " $to)\n\n" . $body);
4✔
3338
            }
3339

3340
            if ($to && !Mail::ourDomain($to)) {
4✔
3341
                # For users who we host, we leave the message unseen; that will then later generate a notification
3342
                # to them.  Otherwise we mail them the message and mark it as seen, because they would get
3343
                # confused by a mail in our notification format.
3344
                $this->mailer($me, TRUE, $this->getName(), $to, NULL, $name, $g->getModsEmail(), $subject, $body);
4✔
3345

3346
                # We've mailed the message out so they are up to date with this chat.
3347
                $r->upToDate($this->id);
4✔
3348
            }
3349

3350
            if ($m) {
4✔
3351
                # We, as a mod, have seen this message - update the roster to show that.  This avoids this message
3352
                # appearing as unread to us.
3353
                $r->updateRoster($myid, $mid);
4✔
3354

3355
                # Ensure that the other mods are present in the roster with the message seen/unseen depending on
3356
                # whether that's what we want.
3357
                $mods = $g->getMods();
4✔
3358
                foreach ($mods as $mod) {
4✔
3359
                    if ($mod != $myid) {
3✔
3360
                        if ($c->getPrivate('chatread')) {
2✔
3361
                            # We want to mark it as seen for all mods.
3362
                            $r->updateRoster($mod, $mid, ChatRoom::STATUS_AWAY);
1✔
3363
                        } else {
3364
                            # Leave it unseen, but make sure they're in the roster.
3365
                            $r->updateRoster($mod, NULL, ChatRoom::STATUS_AWAY);
1✔
3366
                        }
3367
                    }
3368
                }
3369

3370
                if ($c->getPrivate('chatread')) {
4✔
3371
                    $m->setPrivate('mailedtoall', 1);
1✔
3372
                    $m->setPrivate('seenbyall', 1);
1✔
3373
                }
3374

3375
                # Allow mailing to happen.
3376
                $m->setPrivate('reviewrequired', 0);
4✔
3377
            }
3378
        }
3379
    }
3380

3381
    public function mail($groupid, $subject, $body, $stdmsgid, $action = NULL)
3382
    {
3383
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
4✔
3384

3385
        $this->log->log([
4✔
3386
            'type' => Log::TYPE_USER,
4✔
3387
            'subtype' => Log::SUBTYPE_MAILED,
4✔
3388
            'user' => $this->id,
4✔
3389
            'byuser' => $me ? $me->getId() : NULL,
4✔
3390
            'text' => $subject,
4✔
3391
            'groupid' => $groupid,
4✔
3392
            'stdmsgid' => $stdmsgid
4✔
3393
        ]);
4✔
3394

3395
        $this->maybeMail($groupid, $subject, $body, $action);
4✔
3396
    }
3397

3398
    public function happinessReviewed($happinessid) {
3399
        $this->dbhm->preExec("UPDATE messages_outcomes SET reviewed = 1 WHERE id = ?", [
1✔
3400
            $happinessid
1✔
3401
        ]);
1✔
3402
    }
3403

3404
    public function getCommentsForSingleUser($userid) {
3405
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
4✔
3406
        $rets = [
4✔
3407
            $userid => [
4✔
3408
                'id' => $userid
4✔
3409
            ]
4✔
3410
        ];
4✔
3411

3412
        $this->getComments($me, $rets);
4✔
3413

3414
        return Utils::presdef('comments', $rets[$userid], NULL);
4✔
3415
    }
3416

3417
    public function getComments($me, &$rets)
3418
    {
3419
        $userids = array_keys($rets);
194✔
3420

3421
        if ($me && $me->isModerator()) {
194✔
3422
            # Generally there will be no or few comments.  It's quicker (because of indexing) to get them all and filter
3423
            # by groupid than it is to construct a query which includes groupid.  Likewise it's not really worth
3424
            # optimising the calls for byuser, since there won't be any for most users.
3425
            $sql = "SELECT * FROM users_comments WHERE userid IN (" . implode(',', $userids) . ") ORDER BY date DESC;";
78✔
3426
            $comments = $this->dbhr->preQuery($sql);
78✔
3427
            #error_log("Got comments " . var_export($comments, TRUE));
3428

3429
            $commentuids = [];
78✔
3430
            foreach ($comments as $comment) {
78✔
3431
                if (Utils::pres('byuserid', $comment)) {
2✔
3432
                    $commentuids[] = $comment['byuserid'];
2✔
3433
                }
3434
            }
3435

3436
            $commentusers = [];
78✔
3437

3438
            if ($commentuids && count($commentuids)) {
78✔
3439
                $commentusers = $this->getPublicsById($commentuids, NULL, FALSE, FALSE, FALSE, FALSE);
2✔
3440

3441
                foreach ($commentusers as &$commentuser) {
2✔
3442
                    $commentuser['settings'] = NULL;
2✔
3443
                }
3444
            }
3445

3446
            foreach ($rets as $retind => $ret) {
78✔
3447
                $rets[$retind]['comments'] = [];
78✔
3448

3449
                for ($commentind = 0; $commentind < count($comments); $commentind++) {
78✔
3450
                    if ($comments[$commentind]['userid'] == $rets[$retind]['id']) {
2✔
3451
                        $comments[$commentind]['date'] = Utils::ISODate($comments[$commentind]['date']);
2✔
3452
                        $comments[$commentind]['reviewed'] = Utils::ISODate($comments[$commentind]['reviewed']);
2✔
3453

3454
                        if (Utils::pres('byuserid', $comments[$commentind])) {
2✔
3455
                            $comments[$commentind]['byuser'] = $commentusers[$comments[$commentind]['byuserid']];
2✔
3456
                        }
3457

3458
                        $rets[$retind]['comments'][] = $comments[$commentind];
2✔
3459
                    }
3460
                }
3461
            }
3462
        }
3463
    }
3464

3465
    public function listComments(&$ctx, $groupid = NULL) {
3466
        $comments = [];
1✔
3467
        $ctxq = '';
1✔
3468

3469
        // Validate context is an array before use
3470
        if (!is_array($ctx)) {
1✔
3471
            $ctx = [];
1✔
3472
        }
3473

3474
        if ($ctx && Utils::pres('reviewed', $ctx)) {
1✔
3475
            $ctxq = "users_comments.reviewed < " . $this->dbhr->quote($ctx['reviewed']) . " AND ";
1✔
3476
        }
3477

3478
        $groupq = $groupid ? " groupid = $groupid AND " : '';
1✔
3479

3480
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
1✔
3481
        $groupids = $me ? $me->getModeratorships() : [];
1✔
3482

3483
        if (count($groupids)) {
1✔
3484
            $byq = $groupid ? '' : (' OR users_comments.byuserid = ' . $me->getId());
1✔
3485
            $sql = "SELECT * FROM users_comments WHERE $groupq $ctxq (groupid IN (" . implode(',', $groupids) . ") $byq) ORDER BY reviewed desc LIMIT 10;";
1✔
3486
            $comments = $this->dbhr->preQuery($sql);
1✔
3487

3488
            $uids = array_unique(array_merge(array_column($comments, 'byuserid'), array_column($comments, 'userid')));
1✔
3489
            $u = new User($this->dbhr, $this->dbhm);
1✔
3490
            $users = $u->getPublicsById($uids, NULL, FALSE, FALSE, FALSE, FALSE);
1✔
3491

3492
            foreach ($comments as &$comment) {
1✔
3493
                $comment['date'] = Utils::ISODate($comment['date']);
1✔
3494
                $comment['reviewed'] = Utils::ISODate($comment['reviewed']);
1✔
3495

3496
                if (Utils::pres('userid', $comment)) {
1✔
3497
                    $comment['user'] = $users[$comment['userid']];
1✔
3498
                    unset($comment['userid']);
1✔
3499
                }
3500

3501
                if (Utils::pres('byuserid', $comment)) {
1✔
3502
                    $comment['byuser'] = $users[$comment['byuserid']];
1✔
3503
                    unset($comment['byuserid']);
1✔
3504
                }
3505

3506
                $ctx['reviewed'] = $comment['reviewed'];
1✔
3507
            }
3508
        }
3509

3510
        return $comments;
1✔
3511
    }
3512

3513
    public function getComment($id)
3514
    {
3515
        # We can only see comments on groups on which we have mod status.
3516
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
2✔
3517
        $groupids = $me ? $me->getModeratorships() : [];
2✔
3518
        $groupids = count($groupids) == 0 ? [0] : $groupids;
2✔
3519

3520
        $sql = "SELECT * FROM users_comments WHERE id = ? AND groupid IN (" . implode(',', $groupids) . ") ORDER BY date DESC;";
2✔
3521
        $comments = $this->dbhr->preQuery($sql, [$id]);
2✔
3522

3523
        foreach ($comments as &$comment) {
2✔
3524
            $comment['date'] = Utils::ISODate($comment['date']);
2✔
3525
            $comment['reviewed'] = Utils::ISODate($comment['reviewed']);
2✔
3526

3527
            if (Utils::pres('byuserid', $comment)) {
2✔
3528
                $u = User::get($this->dbhr, $this->dbhm, $comment['byuserid']);
2✔
3529
                $comment['byuser'] = $u->getPublic();
2✔
3530
            }
3531

3532
            return ($comment);
2✔
3533
        }
3534

3535
        return (NULL);
1✔
3536
    }
3537

3538
    public function addComment($groupid, $user1 = NULL, $user2 = NULL, $user3 = NULL, $user4 = NULL, $user5 = NULL,
3539
                               $user6 = NULL, $user7 = NULL, $user8 = NULL, $user9 = NULL, $user10 = NULL,
3540
                               $user11 = NULL, $byuserid = NULL, $checkperms = TRUE, $flag = FALSE)
3541
    {
3542
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
7✔
3543

3544
        # By any supplied user else logged in user if any.
3545
        $byuserid = $byuserid ? $byuserid : ($me ? $me->getId() : NULL);
7✔
3546

3547
        # Can only add comments for a group on which we're a mod, or if we are Support adding a global comment.
3548
        $rc = NULL;
7✔
3549
        $groups = $checkperms ? ($me ? $me->getModeratorships() : [0]) : [$groupid];
7✔
3550
        $added = FALSE;
7✔
3551

3552
        foreach ($groups as $modgroupid) {
7✔
3553
            if ($groupid == $modgroupid) {
7✔
3554
                $sql = "INSERT INTO users_comments (userid, groupid, byuserid, user1, user2, user3, user4, user5, user6, user7, user8, user9, user10, user11, flag) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);";
6✔
3555
                $this->dbhm->preExec($sql, [
6✔
3556
                    $this->id,
6✔
3557
                    $groupid,
6✔
3558
                    $byuserid,
6✔
3559
                    $user1, $user2, $user3, $user4, $user5, $user6, $user7, $user8, $user9, $user10, $user11,
6✔
3560
                    $flag ? 1 : 0
6✔
3561
                ]);
6✔
3562

3563
                $rc = $this->dbhm->lastInsertId();
6✔
3564

3565
                $added = TRUE;
6✔
3566
            }
3567
        }
3568

3569
        if (!$added && $me && $me->isAdminOrSupport()) {
7✔
3570
            $sql = "INSERT INTO users_comments (userid, groupid, byuserid, user1, user2, user3, user4, user5, user6, user7, user8, user9, user10, user11, flag) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);";
1✔
3571
            $this->dbhm->preExec($sql, [
1✔
3572
                $this->id,
1✔
3573
                NULL,
1✔
3574
                $byuserid,
1✔
3575
                $user1, $user2, $user3, $user4, $user5, $user6, $user7, $user8, $user9, $user10, $user11,
1✔
3576
                $flag ? 1 : 0
1✔
3577
            ]);
1✔
3578

3579
            $rc = $this->dbhm->lastInsertId();
1✔
3580
        }
3581

3582
        if ($rc && $flag) {
7✔
3583
            $this->flagOthers($groupid);
1✔
3584
        }
3585

3586
        return ($rc);
7✔
3587
    }
3588

3589
    private function flagOthers($groupid) {
3590
        # We want to flag this to any other groups that the member is on.
3591
        $membs = $this->dbhr->preQuery("SELECT groupid FROM memberships WHERE userid = ? AND groupid != ?;", [
2✔
3592
            $this->id,
2✔
3593
            $groupid
2✔
3594
        ]);
2✔
3595

3596
        foreach ($membs as $memb) {
2✔
3597
            $this->memberReview($memb['groupid'], TRUE, 'Note flagged to other groups');
2✔
3598
        }
3599
    }
3600

3601
    public function editComment($id, $user1 = NULL, $user2 = NULL, $user3 = NULL, $user4 = NULL, $user5 = NULL,
3602
                                $user6 = NULL, $user7 = NULL, $user8 = NULL, $user9 = NULL, $user10 = NULL,
3603
                                $user11 = NULL, $flag = FALSE)
3604
    {
3605
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
3✔
3606

3607
        # Update to logged in user if any.
3608
        $byuserid = $me ? $me->getId() : NULL;
3✔
3609

3610
        # Can only edit comments for a group on which we're a mod.  This code isn't that efficient but it doesn't
3611
        # happen often.
3612
        $rc = NULL;
3✔
3613
        $comments = $this->dbhr->preQuery("SELECT id, groupid FROM users_comments WHERE id = ?;", [
3✔
3614
            $id
3✔
3615
        ]);
3✔
3616

3617
        foreach ($comments as $comment) {
3✔
3618
            if ($me && ($me->isAdminOrSupport() || $me->isModOrOwner($comment['groupid']))) {
3✔
3619
                $sql = "UPDATE users_comments SET byuserid = ?, user1 = ?, user2 = ?, user3 = ?, user4 = ?, user5 = ?, user6 = ?, user7 = ?, user8 = ?, user9 = ?, user10 = ?, user11 = ?, reviewed = NOW(), flag = ? WHERE id = ?;";
3✔
3620
                $rc = $this->dbhm->preExec($sql, [
3✔
3621
                    $byuserid,
3✔
3622
                    $user1, $user2, $user3, $user4, $user5, $user6, $user7, $user8, $user9, $user10, $user11,
3✔
3623
                    $flag,
3✔
3624
                    $comment['id']
3✔
3625
                ]);
3✔
3626

3627
                if ($rc && $flag) {
3✔
3628
                    $this->flagOthers($comment['groupid']);
1✔
3629
                }
3630
            }
3631
        }
3632

3633
        return ($rc);
3✔
3634
    }
3635

3636
    public function deleteComment($id)
3637
    {
3638
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
2✔
3639

3640
        # Can only delete comments for a group on which we're a mod.
3641
        $rc = FALSE;
2✔
3642

3643
        $comments = $this->dbhr->preQuery("SELECT id, groupid FROM users_comments WHERE id = ?;", [
2✔
3644
            $id
2✔
3645
        ]);
2✔
3646

3647
        foreach ($comments as $comment) {
2✔
3648
            if ($me && ($me->isAdminOrSupport() || $me->isModOrOwner($comment['groupid']))) {
2✔
3649
                $rc = $this->dbhm->preExec("DELETE FROM users_comments WHERE id = ?;", [$id]);
2✔
3650
            }
3651
        }
3652

3653
        return ($rc);
2✔
3654
    }
3655

3656
    public function deleteComments()
3657
    {
3658
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
1✔
3659

3660
        # Can only delete comments for a group on which we're a mod.
3661
        $rc = FALSE;
1✔
3662
        $groups = $me ? $me->getModeratorships() : [];
1✔
3663
        foreach ($groups as $modgroupid) {
1✔
3664
            $rc = $this->dbhm->preExec("DELETE FROM users_comments WHERE userid = ? AND groupid = ?;", [$this->id, $modgroupid]);
1✔
3665
        }
3666

3667
        return ($rc);
1✔
3668
    }
3669

3670
    public function split($email, $name = NULL)
3671
    {
3672
        # We want to ensure that the current user has no reference to these values.
3673
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
2✔
3674
        $l = new Log($this->dbhr, $this->dbhm);
2✔
3675
        if ($email) {
2✔
3676
            $this->removeEmail($email);
2✔
3677
        }
3678

3679
        $l->log([
2✔
3680
            'type' => Log::TYPE_USER,
2✔
3681
            'subtype' => Log::SUBTYPE_SPLIT,
2✔
3682
            'user' => $this->id,
2✔
3683
            'byuser' => $me ? $me->getId() : NULL,
2✔
3684
            'text' => "Split out $email"
2✔
3685
        ]);
2✔
3686

3687
        $u = new User($this->dbhr, $this->dbhm);
2✔
3688
        $uid2 = $u->create(NULL, NULL, $name);
2✔
3689
        $u->addEmail($email);
2✔
3690

3691
        # We might be able to move some messages over.
3692
        $this->dbhm->preExec("UPDATE messages SET fromuser = ? WHERE fromaddr = ?;", [
2✔
3693
            $uid2,
2✔
3694
            $email
2✔
3695
        ]);
2✔
3696
        $this->dbhm->preExec("UPDATE messages_history SET fromuser = ? WHERE fromaddr = ?;", [
2✔
3697
            $uid2,
2✔
3698
            $email
2✔
3699
        ]);
2✔
3700

3701
        # Chats which reference the messages sent from that email must also be intended for the split user.
3702
        $chats = $this->dbhr->preQuery("SELECT DISTINCT chat_rooms.* FROM chat_rooms INNER JOIN chat_messages ON chat_messages.chatid = chat_rooms.id WHERE refmsgid IN (SELECT id FROM messages WHERE fromaddr = ?);", [
2✔
3703
            $email
2✔
3704
        ]);
2✔
3705

3706
        foreach ($chats as $chat) {
2✔
3707
            if ($chat['user1'] == $this->id) {
1✔
3708
                $this->dbhm->preExec("UPDATE chat_rooms SET user1 = ? WHERE id = ?;", [
1✔
3709
                    $uid2,
1✔
3710
                    $chat['id']
1✔
3711
                ]);
1✔
3712
            }
3713

3714
            if ($chat['user2'] == $this->id) {
1✔
3715
                $this->dbhm->preExec("UPDATE chat_rooms SET user2 = ? WHERE id = ?;", [
1✔
3716
                    $uid2,
1✔
3717
                    $chat['id']
1✔
3718
                ]);
1✔
3719
            }
3720
        }
3721

3722
        # We might have a name.
3723
        $this->dbhm->preExec("UPDATE users SET fullname = (SELECT fromname FROM messages WHERE fromaddr = ? LIMIT 1) WHERE id = ?;", [
2✔
3724
            $email,
2✔
3725
            $uid2
2✔
3726
        ]);
2✔
3727

3728
        # Zap any existing sessions for either.
3729
        $this->dbhm->preExec("DELETE FROM sessions WHERE userid IN (?, ?);", [$this->id, $uid2]);
2✔
3730

3731
        # We can't tell which user any existing logins relate to.  So remove them all.  If they log in with native,
3732
        # then they'll have to get a new password.  If they use social login, then it should hook the user up again
3733
        # when they next do.
3734
        $this->dbhm->preExec("DELETE FROM users_logins WHERE userid = ?;", [$this->id]);
2✔
3735

3736
        return ($uid2);
2✔
3737
    }
3738

3739
    public function FBL()
3740
    {
3741
        $settings = $this->loginLink(USER_SITE, $this->id, '/settings', User::SRC_FBL, TRUE);
1✔
3742
        $unsubscribe = $this->loginLink(USER_SITE, $this->id, '/settings', User::SRC_FBL, TRUE);
1✔
3743

3744
        $loader = new \Twig_Loader_Filesystem(IZNIK_BASE . '/mailtemplates/twig');
1✔
3745
        $twig = new \Twig_Environment($loader);
1✔
3746

3747
        $html = $twig->render('fbl.html', [
1✔
3748
            'email' => $this->getEmailPreferred(),
1✔
3749
            'unsubscribe' => $unsubscribe,
1✔
3750
            'settings' => $settings
1✔
3751
        ]);
1✔
3752

3753
        $message = \Swift_Message::newInstance()
1✔
3754
            ->setSubject("We've turned off emails for you")
1✔
3755
            ->setFrom([NOREPLY_ADDR => SITE_NAME])
1✔
3756
            ->setTo($this->getEmailPreferred())
1✔
3757
//            ->setBcc('log@ehibbert.org.uk')
1✔
3758
            ->setBody("You marked a mail as spam, so we've turned off your emails.  You can leave Freegle completely from $unsubscribe or turn them back on from $settings.");
1✔
3759

3760
        # Add HTML in base-64 as default quoted-printable encoding leads to problems on
3761
        # Outlook.
3762
        $htmlPart = \Swift_MimePart::newInstance();
1✔
3763
        $htmlPart->setCharset('utf-8');
1✔
3764
        $htmlPart->setEncoder(new \Swift_Mime_ContentEncoder_Base64ContentEncoder);
1✔
3765
        $htmlPart->setContentType('text/html');
1✔
3766
        $htmlPart->setBody($html);
1✔
3767
        $message->attach($htmlPart);
1✔
3768

3769
        Mail::addHeaders($this->dbhr, $this->dbhm, $message, Mail::FBL_OFF, $this->getId());
1✔
3770

3771
        list ($transport, $mailer) = Mail::getMailer();
1✔
3772
        $this->sendIt($mailer, $message);
1✔
3773
    }
3774

3775
    public function forgotPassword($email)
3776
    {
3777
        $link = $this->loginLink(USER_SITE, $this->id, '/settings', User::SRC_FORGOT_PASSWORD, TRUE);
1✔
3778

3779
        $loader = new \Twig_Loader_Filesystem(IZNIK_BASE . '/mailtemplates/twig/welcome');
1✔
3780
        $twig = new \Twig_Environment($loader);
1✔
3781

3782
        $html = $twig->render('forgotpassword.html', [
1✔
3783
            'email' => $this->getEmailPreferred(),
1✔
3784
            'url' => $link,
1✔
3785
        ]);
1✔
3786

3787
        $message = \Swift_Message::newInstance()
1✔
3788
            ->setSubject("Forgot your password?")
1✔
3789
            ->setFrom([NOREPLY_ADDR => SITE_NAME])
1✔
3790
            ->setTo($email)
1✔
3791
            ->setBody("To set a new password, just log in here: $link");
1✔
3792

3793
        # Add HTML in base-64 as default quoted-printable encoding leads to problems on
3794
        # Outlook.
3795
        $htmlPart = \Swift_MimePart::newInstance();
1✔
3796
        $htmlPart->setCharset('utf-8');
1✔
3797
        $htmlPart->setEncoder(new \Swift_Mime_ContentEncoder_Base64ContentEncoder);
1✔
3798
        $htmlPart->setContentType('text/html');
1✔
3799
        $htmlPart->setBody($html);
1✔
3800
        $message->attach($htmlPart);
1✔
3801

3802
        Mail::addHeaders($this->dbhr, $this->dbhm, $message, Mail::FORGOT_PASSWORD, $this->getId());
1✔
3803

3804
        list ($transport, $mailer) = Mail::getMailer();
1✔
3805
        $this->sendIt($mailer, $message);
1✔
3806
    }
3807

3808
    public function verifyEmail($email, $force = FALSE)
3809
    {
3810
        # If this is one of our current emails, then we can just make it the primary.
3811
        $emails = $this->getEmails();
6✔
3812
        $handled = FALSE;
6✔
3813

3814
        if (!$force) {
6✔
3815
            foreach ($emails as $anemail) {
6✔
3816
                if (User::canonMail($anemail['email']) == User::canonMail($email)) {
6✔
3817
                    # It's one of ours already; make sure it's flagged as primary.
3818
                    $this->addEmail($email, 1);
3✔
3819
                    $handled = TRUE;
3✔
3820
                }
3821
            }
3822
        }
3823

3824
        if (!$handled) {
6✔
3825
            # This email is new to this user.  It may or may not currently be in use for another user.  Either
3826
            # way we want to send a verification mail.
3827
            $usersite = strpos($_SERVER['HTTP_HOST'], USER_SITE) !== FALSE || strpos($_SERVER['HTTP_HOST'], 'fdapi') !== FALSE;
5✔
3828
            $headers = "From: " . SITE_NAME . " <" . NOREPLY_ADDR . ">\nContent-Type: multipart/alternative; boundary=\"_I_Z_N_I_K_\"\nMIME-Version: 1.0";
5✔
3829
            $canon = User::canonMail($email);
5✔
3830

3831
            # We might be in the process of validating - sometimes people get confused and do this repeatedly.  So
3832
            # don't alter the key if it's only recently been set.
3833
            $existing = $this->dbhr->preQuery("SELECT validatekey FROM users_emails WHERE canon = ? AND TIMESTAMPDIFF(SECOND, validatetime, NOW()) < 600;", [
5✔
3834
                $canon
5✔
3835
            ]);
5✔
3836

3837
            $key = count($existing) ? $existing[0]['validatekey'] : NULL;
5✔
3838

3839
            if (!$key) {
5✔
3840
                do {
3841
                    # Loop in case of clash on the key we happen to invent.
3842
                    $key = uniqid();
5✔
3843
                    $sql = "INSERT INTO users_emails (email, canon, validatekey, backwards) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE validatekey = ?;";
5✔
3844
                    $this->dbhm->preExec($sql,
5✔
3845
                                         [$email, $canon, $key, strrev($canon), $key]);
5✔
3846
                } while (!$this->dbhm->rowsAffected());
5✔
3847
            }
3848

3849
            $confirm = $this->loginLink(USER_SITE, $this->id, "/settings/confirmmail/" . urlencode($key), 'changeemail', TRUE);
5✔
3850

3851
            list ($transport, $mailer) = Mail::getMailer();
5✔
3852
            $loader = new \Twig_Loader_Filesystem(IZNIK_BASE . '/mailtemplates/twig');
5✔
3853
            $twig = new \Twig_Environment($loader);
5✔
3854

3855
            $html = $twig->render('verifymail.html', [
5✔
3856
                'email' => $email,
5✔
3857
                'confirm' => $confirm
5✔
3858
            ]);
5✔
3859

3860
            $message = \Swift_Message::newInstance()
5✔
3861
                ->setSubject("Please verify your email")
5✔
3862
                ->setFrom([NOREPLY_ADDR => SITE_NAME])
5✔
3863
                ->setReturnPath($this->getBounce())
5✔
3864
                ->setTo([$email => $this->getName()])
5✔
3865
                ->setBody("Someone, probably you, has said that $email is their email address.\n\nIf this was you, please click on the link below to verify the address; if this wasn't you, please just ignore this mail.\n\n$confirm");
5✔
3866

3867
            # Add HTML in base-64 as default quoted-printable encoding leads to problems on
3868
            # Outlook.
3869
            $htmlPart = \Swift_MimePart::newInstance();
5✔
3870
            $htmlPart->setCharset('utf-8');
5✔
3871
            $htmlPart->setEncoder(new \Swift_Mime_ContentEncoder_Base64ContentEncoder);
5✔
3872
            $htmlPart->setContentType('text/html');
5✔
3873
            $htmlPart->setBody($html);
5✔
3874
            $message->attach($htmlPart);
5✔
3875

3876
            Mail::addHeaders($this->dbhr, $this->dbhm, $message, Mail::VERIFY_EMAIL, $this->getId());
5✔
3877

3878
            $this->sendIt($mailer, $message);
5✔
3879
        }
3880

3881
        return ($handled);
6✔
3882
    }
3883

3884
    public function confirmEmail($key)
3885
    {
3886
        $rc = FALSE;
2✔
3887
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
2✔
3888

3889
        $sql = "SELECT * FROM users_emails WHERE validatekey = ?;";
2✔
3890
        $mails = $this->dbhr->preQuery($sql, [$key]);
2✔
3891

3892
        foreach ($mails as $mail) {
2✔
3893
            if ($mail['userid'] && $mail['userid'] != $me->getId()) {
2✔
3894
                # This email belongs to another user.  But we've confirmed that it is ours.  So merge.
3895
                $this->merge($this->id, $mail['userid'], "Verified ownership of email {$mail['email']}");
2✔
3896
            }
3897

3898
            $this->dbhm->preExec("UPDATE users_emails SET preferred = 0 WHERE userid = ?;", [$this->id]);
2✔
3899
            $this->dbhm->preExec("UPDATE users_emails SET userid = ?, preferred = 1, validated = NOW(), validatekey = NULL WHERE id = ?;", [$this->id, $mail['id']]);
2✔
3900
            $this->addEmail($mail['email'], 1);
2✔
3901

3902
            $rc = $this->id;
2✔
3903
        }
3904

3905
        return ($rc);
2✔
3906
    }
3907

3908
    public function confirmUnsubscribe()
3909
    {
3910
        list ($transport, $mailer) = Mail::getMailer();
2✔
3911

3912
        $link = $this->getUnsubLink(USER_SITE, $this->id, NULL, TRUE) . "&confirm=1";
2✔
3913

3914
        $message = \Swift_Message::newInstance()
2✔
3915
            ->setSubject("Please confirm you want to leave Freegle")
2✔
3916
            ->setFrom(NOREPLY_ADDR)
2✔
3917
            ->setReplyTo(SUPPORT_ADDR)
2✔
3918
            ->setTo($this->getEmailPreferred())
2✔
3919
            ->setDate(time())
2✔
3920
            ->setBody("Please click here to leave Freegle:\r\n\r\n$link\r\n\r\nThis will remove all your data and cannot be undone.  If you just want to leave a Freegle or reduce the number of emails you get, please sign in and go to Settings.\r\n\r\nIf you didn't try to leave, please ignore this mail.\r\n\r\nThanks for freegling, and do please come back in the future.");
2✔
3921

3922
        Mail::addHeaders($this->dbhr, $this->dbhm, $message, Mail::UNSUBSCRIBE);
2✔
3923
        $this->sendIt($mailer, $message);
2✔
3924
    }
3925

3926
    public function inventEmail($force = FALSE)
3927
    {
3928
        # An invented email is one on our domain that doesn't give away too much detail, but isn't just a string of
3929
        # numbers (ideally).  We may already have one.
3930
        $email = NULL;
45✔
3931

3932
        if (!$force) {
45✔
3933
            # We want the most recent of our own emails.
3934
            $emails = $this->getEmails(TRUE);
45✔
3935
            foreach ($emails as $thisemail) {
45✔
3936
                if (strpos($thisemail['email'], USER_DOMAIN) !== FALSE) {
28✔
3937
                    $email = $thisemail['email'];
15✔
3938
                    break;
15✔
3939
                }
3940
            }
3941
        }
3942

3943
        if (!$email) {
45✔
3944
            # If they have a Yahoo ID, that'll do nicely - it's public info.  But some Yahoo IDs are actually
3945
            # email addresses (don't ask) and we don't want those.  And some are stupidly long.
3946
            $yahooid = $this->getPrivate('yahooid');
32✔
3947

3948
            if (!$force && strlen(str_replace(' ', '', $yahooid)) && strpos($yahooid, '@') === FALSE && strlen($yahooid) <= 16) {
32✔
3949
                $email = str_replace(' ', '', $yahooid) . '-' . $this->id . '@' . USER_DOMAIN;
1✔
3950
            } else {
3951
                # Their own email might already be of that nature, which would be lovely.
3952
                if (!$force) {
32✔
3953
                    $email = $this->getEmailPreferred();
32✔
3954

3955
                    if ($email) {
32✔
3956
                        foreach (['firstname', 'lastname', 'fullname'] as $att) {
14✔
3957
                            $words = explode(' ', $this->user[$att]);
14✔
3958
                            foreach ($words as $word) {
14✔
3959
                                if (strlen($word) && stripos($email, $word) !== FALSE) {
14✔
3960
                                    # Unfortunately not - it has some personal info in it.
3961
                                    $email = NULL;
14✔
3962
                                }
3963
                            }
3964
                        }
3965

3966
                        if (stripos($email, '%') !== FALSE) {
14✔
3967
                            # This may indicate a case where the real email is encoded on the LHS, eg gtempaccount.com
3968
                            $email = NULL;
1✔
3969
                        }
3970

3971
                        if (stripos($email, SITE_NAME) !== FALSE || stripos($email, 'freegle') !== FALSE) {
14✔
3972
                            $email = NULL;
1✔
3973
                        }
3974
                    }
3975
                }
3976

3977
                if ($email) {
32✔
3978
                    # We have an email which is fairly anonymous.  Use the LHS.
3979
                    $p = strpos($email, '@');
1✔
3980
                    $email = str_replace(' ', '', $p > 0 ? substr($email, 0, $p) : $email) . '-' . $this->id . '@' . USER_DOMAIN;
1✔
3981
                } else {
3982
                    # We can't make up something similar to their existing email address so invent from scratch.
3983
                    $lengths = json_decode(file_get_contents(IZNIK_BASE . '/lib/wordle/data/distinct_word_lengths.json'), TRUE);
32✔
3984
                    $bigrams = json_decode(file_get_contents(IZNIK_BASE . '/lib/wordle/data/word_start_bigrams.json'), TRUE);
32✔
3985
                    $trigrams = json_decode(file_get_contents(IZNIK_BASE . '/lib/wordle/data/trigrams.json'), TRUE);
32✔
3986

3987
                    # Build list of words to exclude from invented email - includes name parts and any
3988
                    # words from %encoded emails (like real%test.com@gtempaccount.com -> "test")
3989
                    $excludeWords = [];
32✔
3990

3991
                    foreach (['firstname', 'lastname', 'fullname'] as $att) {
32✔
3992
                        $words = explode(' ', $this->user[$att]);
32✔
3993
                        foreach ($words as $word) {
32✔
3994
                            $word = trim($word);
32✔
3995
                            if (strlen($word) >= 3 && $word !== '-') {
32✔
3996
                                $excludeWords[] = strtolower($word);
23✔
3997
                            }
3998
                        }
3999
                    }
4000

4001
                    # Check for %encoded emails and extract the encoded domain parts
4002
                    $preferredEmail = $this->getEmailPreferred();
32✔
4003
                    if ($preferredEmail && strpos($preferredEmail, '%') !== FALSE) {
32✔
4004
                        # Extract the LHS before @ which contains the encoded real email
4005
                        $atPos = strpos($preferredEmail, '@');
1✔
4006
                        if ($atPos !== FALSE) {
1✔
4007
                            $lhs = substr($preferredEmail, 0, $atPos);
1✔
4008
                            # Split on % and . to get individual words
4009
                            $parts = preg_split('/[%.@]/', $lhs);
1✔
4010
                            foreach ($parts as $part) {
1✔
4011
                                $part = trim($part);
1✔
4012
                                if (strlen($part) >= 3) {
1✔
4013
                                    $excludeWords[] = strtolower($part);
1✔
4014
                                }
4015
                            }
4016
                        }
4017
                    }
4018

4019
                    do {
4020
                        $length = \Wordle\array_weighted_rand($lengths);
32✔
4021
                        $start = \Wordle\array_weighted_rand($bigrams);
32✔
4022
                        $email = strtolower(\Wordle\fill_word($start, $length, $trigrams)) . '-' . $this->id . '@' . USER_DOMAIN;
32✔
4023

4024
                        # Check that invented email doesn't contain any excluded words
4025
                        $q = strpos($email, '-');
32✔
4026
                        $wordPart = substr($email, 0, $q);
32✔
4027

4028
                        foreach ($excludeWords as $word) {
32✔
4029
                            if (stripos($wordPart, $word) !== FALSE) {
23✔
4030
                                $email = NULL;
×
4031
                                break;
×
4032
                            }
4033
                        }
4034
                    } while (!$email);
32✔
4035
                }
4036
            }
4037
        }
4038

4039
        return ($email);
45✔
4040
    }
4041

4042
    public function delete($groupid = NULL, $subject = NULL, $body = NULL, $log = TRUE)
4043
    {
4044
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
18✔
4045

4046
        # Delete memberships.  This will remove any Yahoo memberships.
4047
        $membs = $this->getMemberships();
18✔
4048
        #error_log("Members in delete " . var_export($membs, TRUE));
4049
        foreach ($membs as $memb) {
18✔
4050
            $this->removeMembership($memb['id']);
8✔
4051
        }
4052

4053
        $rc = $this->dbhm->preExec("DELETE FROM users WHERE id = ?;", [$this->id]);
18✔
4054

4055
        if ($rc && $log) {
18✔
4056
            $this->log->log([
5✔
4057
                'type' => Log::TYPE_USER,
5✔
4058
                'subtype' => Log::SUBTYPE_DELETED,
5✔
4059
                'user' => $this->id,
5✔
4060
                'byuser' => $me ? $me->getId() : NULL,
5✔
4061
                'text' => $this->getName()
5✔
4062
            ]);
5✔
4063
        }
4064

4065
        return ($rc);
18✔
4066
    }
4067

4068
    public function getUnsubLink($domain, $id, $type = NULL, $auto = FALSE)
4069
    {
4070
        return (User::loginLink($domain, $id, "/unsubscribe/$id", $type, $auto));
3✔
4071
    }
4072

4073
    public function listUnsubscribe($id, $type = NULL) {
4074
        # These are links which will completely unsubscribe the user.  This is necessary because of Yahoo and Gmail
4075
        # changes in 2024, and also useful for CAN-SPAM.  We want them to involve the key to prevent spoof unsubscribes.
4076
        #
4077
        # We only include the web link, because this providers a better user experience - we can tell them
4078
        # things afterwards.  This is valid - RFC8058 the RFC says you MUST include an HTTPS link, and you MAY
4079
        # include others.
4080
        $key = $id ? $this->getUserKey($id) : '1234';
108✔
4081
        $key = $key ? $key : '1234';
107✔
4082
        #$ret = "<mailto:unsubscribe-$id-$key-$type@" . USER_DOMAIN . "?subject=unsubscribe>, <https://" . USER_SITE . "/one-click-unsubscribe/$id/$key>";
4083
        $ret = "<https://" . USER_SITE . "/one-click-unsubscribe/$id/$key>";
107✔
4084
        #$ret = "<http://localhost:3002/one-click-unsubscribe/$id/$key>";
4085
        return $ret;
107✔
4086
    }
4087

4088
    public function loginLink($domain, $id, $url = '/', $type = NULL, $auto = FALSE)
4089
    {
4090
        $p = strpos($url, '?');
35✔
4091
        $ret = $p === FALSE ? "https://$domain$url?u=$id&src=$type" : "https://$domain$url&u=$id&src=$type";
35✔
4092

4093
        if ($auto) {
35✔
4094
            # Get a per-user link we can use to log in without a password.
4095
            $key = $this->getUserKey($id);
11✔
4096

4097
            # If this didn't work, we still return an URL - worst case they'll have to sign in.
4098
            $key = $key ? $key : null;
11✔
4099

4100
            $p = strpos($url, '?');
11✔
4101
            $src = $type ? "&src=$type" : "";
11✔
4102
            $ret = $p === FALSE ? ("https://$domain$url?u=$id&k=$key$src") : ("https://$domain$url&u=$id&k=$key$src");
11✔
4103
        }
4104

4105
        return ($ret);
35✔
4106
    }
4107

4108
    public function sendOurMails($g = NULL, $checkholiday = TRUE, $checkbouncing = TRUE)
4109
    {
4110
        if ($this->getPrivate('deleted')) {
84✔
4111
            return FALSE;
1✔
4112
        }
4113

4114
        # We have two kinds of email settings - the top-level Simple one, and more detailed per group ones.
4115
        # Where present the Simple one overrides the group ones, so check that first.
4116
        $simplemail = $this->getSetting('simplemail', NULL);
84✔
4117
        if ($simplemail === User::SIMPLE_MAIL_NONE) {
84✔
4118
            return FALSE;
×
4119
        }
4120

4121
        # We don't want to send emails to people who haven't been active for more than six months.  This improves
4122
        # our spam reputation, by avoiding honeytraps.
4123
        $sendit = FALSE;
84✔
4124
        $lastaccess = strtotime($this->getPrivate('lastaccess'));
84✔
4125

4126
        // This time is also present on the client in ModMember, and in Engage.
4127
        if (time() - $lastaccess <= Engage::USER_INACTIVE) {
84✔
4128
            $sendit = TRUE;
84✔
4129

4130
            if ($sendit && $checkholiday) {
84✔
4131
                # We might be on holiday.
4132
                $hol = $this->getPrivate('onholidaytill');
23✔
4133
                $till = $hol ? strtotime($hol) : 0;
23✔
4134
                #error_log("Holiday $till vs " . time());
4135

4136
                $sendit = time() > $till;
23✔
4137
            }
4138

4139
            if ($sendit && $checkbouncing) {
84✔
4140
                # And don't send if we're bouncing.
4141
                $sendit = !$this->getPrivate('bouncing');
23✔
4142
                #error_log("After bouncing $sendit");
4143
            }
4144
        }
4145

4146
        #error_log("Sendit? $sendit");
4147
        return ($sendit);
84✔
4148
    }
4149

4150
    public function getMembershipHistory()
4151
    {
4152
        # We get this from our logs.
4153
        $sql = "SELECT * FROM logs WHERE user = ? AND `type` = ? ORDER BY id DESC;";
5✔
4154
        $logs = $this->dbhr->preQuery($sql, [$this->id, Log::TYPE_GROUP]);
5✔
4155

4156
        $ret = [];
5✔
4157
        foreach ($logs as $log) {
5✔
4158
            $thisone = NULL;
3✔
4159
            switch ($log['subtype']) {
3✔
4160
                case Log::SUBTYPE_JOINED:
3✔
4161
                case Log::SUBTYPE_APPROVED:
1✔
4162
                case Log::SUBTYPE_REJECTED:
1✔
4163
                case Log::SUBTYPE_APPLIED:
1✔
4164
                case Log::SUBTYPE_LEFT:
1✔
4165
                    {
3✔
4166
                        $thisone = $log['subtype'];
3✔
4167
                        break;
3✔
4168
                    }
3✔
4169
            }
4170

4171
            #error_log("{$log['subtype']} gives $thisone {$log['groupid']}");
4172
            if ($thisone && $log['groupid']) {
3✔
4173
                $g = Group::get($this->dbhr, $this->dbhm, $log['groupid']);
3✔
4174

4175
                if ($g->getId() ==  $log['groupid']) {
3✔
4176
                    $ret[] = [
3✔
4177
                        'timestamp' => Utils::ISODate($log['timestamp']),
3✔
4178
                        'type' => $thisone,
3✔
4179
                        'group' => [
3✔
4180
                            'id' => $log['groupid'],
3✔
4181
                            'nameshort' => $g->getPrivate('nameshort'),
3✔
4182
                            'namedisplay' => $g->getName()
3✔
4183
                        ],
3✔
4184
                        'text' => $log['text']
3✔
4185
                    ];
3✔
4186
                }
4187
            }
4188
        }
4189

4190
        return ($ret);
5✔
4191
    }
4192

4193
    public function search($search, $ctx)
4194
    {
4195
        if (preg_replace('/\-|\~/', '', $search) ==  '') {
5✔
4196
            # Most likely an encoded id.
4197
            $search = User::decodeId($search);
×
4198
        }
4199

4200
        if (preg_match('/story-(.*)/', $search, $matches)) {
5✔
4201
            # Story.
4202
            $s = new Story($this->dbhr, $this->dbhm, $matches[1]);
×
4203
            $search = $s->getPrivate('userid');
×
4204
        }
4205

4206
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
5✔
4207
        $id = intval(Utils::presdef('id', $ctx, 0));
5✔
4208
        $ctx = $ctx ? $ctx : [];
5✔
4209
        $q = $this->dbhr->quote("$search%");
5✔
4210
        $backwards = strrev($search);
5✔
4211
        $qb = $this->dbhr->quote("$backwards%");
5✔
4212

4213
        $canon = $this->dbhr->quote(User::canonMail($search) . "%");
5✔
4214

4215
        # canonMail might not strip the dots out if we don't have a full email to search on, but for this Support Tools
4216
        # search we want to try quite hard.
4217
        $canon2 = $this->dbhr->quote(User::canonMail(str_replace('.', '', $search)) . "%");
5✔
4218

4219
        # If we're searching for a notify address, switch to the user it.
4220
        $search = preg_match('/notify-(.*)-(.*)' . USER_DOMAIN . '/', $search, $matches) ? $matches[2] : $search;
5✔
4221

4222
        $sql = "SELECT DISTINCT userid FROM
5✔
4223
                ((SELECT userid FROM users_emails WHERE canon LIKE $canon OR backwards LIKE $qb) UNION
5✔
4224
                (SELECT userid FROM users_emails WHERE canon LIKE $canon2) UNION
5✔
4225
                (SELECT id AS userid FROM users WHERE fullname LIKE $q) UNION
5✔
4226
                (SELECT id AS userid FROM users WHERE yahooid LIKE $q) UNION
5✔
4227
                (SELECT id AS userid FROM users WHERE id = ?) UNION
4228
                (SELECT userid FROM users_logins WHERE uid LIKE $q)) t WHERE userid > ? ORDER BY userid ASC";
5✔
4229
        $users = $this->dbhr->preQuery($sql, [$search, $id]);
5✔
4230

4231
        $ret = [];
5✔
4232

4233
        foreach ($users as $user) {
5✔
4234
            $ctx['id'] = $user['userid'];
4✔
4235

4236
            $u = User::get($this->dbhr, $this->dbhm, $user['userid']);
4✔
4237

4238
            $thisone = $u->getPublic(NULL, TRUE, TRUE, TRUE, TRUE, FALSE, TRUE, [
4✔
4239
                MessageCollection::PENDING,
4✔
4240
                MessageCollection::APPROVED
4✔
4241
            ], TRUE);
4✔
4242

4243
            # We might not have the emails.
4244
            $thisone['email'] = $u->getEmailPreferred();
4✔
4245
            $thisone['emails'] = $u->getEmails();
4✔
4246

4247
            $thisone['membershiphistory'] = $u->getMembershipHistory();
4✔
4248

4249
            # Make sure there's a link login as admin/support can use that to impersonate.
4250
            if ($me && ($me->isAdmin() || ($me->isAdminOrSupport() && !$u->isModerator()))) {
4✔
4251
                $thisone['loginlink'] = $u->loginLink(USER_SITE, $user['userid'], '/', NULL, TRUE);
×
4252
            }
4253

4254
            $thisone['logins'] = $u->getLogins($me && $me->isAdmin());
4✔
4255

4256
            # Also return the chats for this user.  Can't use ChatRooms::listForUser because that would exclude any
4257
            # chats on groups where we were no longer a member.
4258
            $rooms = array_filter(array_column($this->dbhr->preQuery("SELECT id FROM chat_rooms WHERE user1 = ? UNION SELECT id FROM chat_rooms WHERE chattype = ? AND user2 = ?;", [
4✔
4259
                $user['userid'],
4✔
4260
                ChatRoom::TYPE_USER2USER,
4✔
4261
                $user['userid'],
4✔
4262
            ]), 'id'));
4✔
4263

4264
            $thisone['chatrooms'] = [];
4✔
4265

4266
            if ($rooms) {
4✔
4267
                $r = new ChatRoom($this->dbhr, $this->dbhm);
×
4268
                $thisone['chatrooms'] = $r->fetchRooms($rooms, $user['userid'], FALSE);
×
4269
            }
4270

4271
            # Add the public location and best guess lat/lng
4272
            $thisone['info']['publiclocation'] = $u->getPublicLocation();
4✔
4273
            $latlng = $u->getLatLng(FALSE, TRUE);
4✔
4274
            $thisone['privateposition'] = [
4✔
4275
                'lat' => $latlng[0],
4✔
4276
                'lng' => $latlng[1],
4✔
4277
                'name' => $latlng[2]
4✔
4278
            ];
4✔
4279

4280
            $thisone['comments'] = $this->getCommentsForSingleUser($user['userid']);
4✔
4281
            $thisone['tnuserid'] = $u->getPrivate('tnuserid');
4✔
4282

4283
            $push = $this->dbhr->preQuery("SELECT MAX(lastsent) AS lastpush FROM users_push_notifications WHERE userid = ?;", [
4✔
4284
                $user['userid']
4✔
4285
            ]);
4✔
4286

4287
            foreach ($push as $p) {
4✔
4288
                $thisone['lastpush'] = Utils::ISODate($p['lastpush']);
4✔
4289
            }
4290

4291
            $thisone['info'] = $u->getInfo();
4✔
4292
            $thisone['trustlevel'] = $u->getPrivate('trustlevel');
4✔
4293

4294
            $bans = $this->dbhr->preQuery("SELECT * FROM users_banned WHERE userid = ?;", [
4✔
4295
                $u->getId()
4✔
4296
            ]);
4✔
4297

4298
            $thisone['bans'] = [];
4✔
4299

4300
            foreach ($bans as $ban) {
4✔
4301
                $g = Group::get($this->dbhr, $this->dbhm, $ban['groupid']);
1✔
4302
                $banner = User::get($this->dbhr, $this->dbhm, $ban['byuser']);
1✔
4303
                $thisone['bans'][] = [
1✔
4304
                    'date' => Utils::ISODate($ban['date']),
1✔
4305
                    'group' => $g->getName(),
1✔
4306
                    'byemail' => $banner->getEmailPreferred(),
1✔
4307
                    'byuserid' => $ban['byuser']
1✔
4308
                ];
1✔
4309
            }
4310

4311
            $d = new Donations($this->dbhr, $this->dbhm);
4✔
4312
            $thisone['giftaid'] = $d->getGiftAid($user['userid']);
4✔
4313

4314
            if ($me->hasPermission(User::PERM_GIFTAID)) {
4✔
4315
                $thisone['donations'] = $d->listByUser($user['userid']);
2✔
4316
            }
4317

4318
            $thisone['newsfeedmodstatus'] = $u->getPrivate('newsfeedmodstatus');
4✔
4319
            $thisone['newsfeed'] = $this->dbhr->preQuery("SELECT id, message, timestamp, hidden, hiddenby, deleted, deletedby FROM newsfeed WHERE userid = ? ORDER BY id DESC;", [
4✔
4320
                $user['userid']
4✔
4321
            ]);
4✔
4322

4323
            foreach ($thisone['newsfeed'] as &$nf) {
4✔
4324
                $nf['timestamp'] = Utils::ISODate($nf['timestamp']);
×
4325
                $nf['deleted'] = Utils::ISODate($nf['deleted']);
×
4326
                $nf['hidden'] = Utils::ISODate($nf['hidden']);
×
4327
            }
4328

4329
            $ret[] = $thisone;
4✔
4330
        }
4331

4332
        return ($ret);
5✔
4333
    }
4334

4335
    private function safeGetPostcode($val) {
4336
        $ret = [ NULL, NULL ];
50✔
4337

4338
        $settings = $val ? json_decode($val, TRUE) : [];
50✔
4339

4340
        if (Utils::pres('mylocation', $settings) &&
50✔
4341
            Utils::presdef('type', $settings['mylocation'], NULL) == 'Postcode') {
50✔
4342
            $ret = [
13✔
4343
                Utils::presdef('id', $settings['mylocation'], NULL),
13✔
4344
                Utils::presdef('name', $settings['mylocation'], NULL)
13✔
4345
            ];
13✔
4346
        }
4347

4348
        return $ret;
50✔
4349
    }
4350

4351
    public function setPrivate($att, $val)
4352
    {
4353
        if (!strcmp($att, 'settings') && $val) {
184✔
4354
            # Possible location change.
4355
            list ($oldid, $oldloc) = $this->safeGetPostcode($this->getPrivate('settings'));
50✔
4356
            list ($newid, $newloc) = $this->safeGetPostcode($val);
50✔
4357

4358
            if ($oldloc !== $newloc) {
50✔
4359
                # We have changed our location.
4360
                parent::setPrivate('lastlocation', $newid);
13✔
4361
                $i = new Isochrone($this->dbhr, $this->dbhm);
13✔
4362
                $i->deleteForUser($this->id);
13✔
4363

4364
                $this->log->log([
13✔
4365
                            'type' => Log::TYPE_USER,
13✔
4366
                            'subtype' => Log::SUBTYPE_POSTCODECHANGE,
13✔
4367
                            'user' => $this->id,
13✔
4368
                            'text' => $newloc
13✔
4369
                        ]);
13✔
4370
            }
4371

4372
            // Prune the info in the settings to remove any groupsnear info, which would use space and is not needed.
4373
            $val = User::pruneSettings($val);
50✔
4374
        }
4375

4376
        User::clearCache($this->id);
184✔
4377
        parent::setPrivate($att, $val);
184✔
4378
    }
4379

4380
    public static function pruneSettings($val) {
4381
        // Prune info from what we store in the user table to keep it smaller.
4382
        if (strpos($val, 'groupsnear') !== FALSE) {
50✔
4383
            $decoded = json_decode($val, TRUE);
×
4384
            if (Utils::pres('mylocation', $decoded) && Utils::pres('groupsnear', $decoded['mylocation'])) {
×
4385
                unset($decoded['mylocation']['groupsnear']);
×
4386
                $val = json_encode($decoded);
×
4387
            }
4388
        }
4389

4390
        return $val;
50✔
4391
    }
4392

4393
    public function canMerge()
4394
    {
4395
        $settings = Utils::pres('settings', $this->user) ? json_decode($this->user['settings'], TRUE) : [];
16✔
4396
        return (array_key_exists('canmerge', $settings) ? $settings['canmerge'] : TRUE);
16✔
4397
    }
4398

4399
    public function notifsOn($type, $groupid = NULL)
4400
    {
4401
        if ($this->getPrivate('deleted')) {
60✔
4402
            return FALSE;
1✔
4403
        }
4404

4405
        $settings = Utils::pres('settings', $this->user) ? json_decode($this->user['settings'], TRUE) : [];
60✔
4406
        $notifs = Utils::pres('notifications', $settings);
60✔
4407

4408
        $defs = [
60✔
4409
            self::NOTIFS_EMAIL => TRUE,
60✔
4410
            self::NOTIFS_EMAIL_MINE => FALSE,
60✔
4411
            self::NOTIFS_PUSH => TRUE
60✔
4412
        ];
60✔
4413

4414
        $ret = ($notifs && array_key_exists($type, $notifs)) ? $notifs[$type] : $defs[$type];
60✔
4415

4416
        if ($ret && $groupid) {
60✔
4417
            # Check we're an active mod on this group - if not then we don't want the notifications.
4418
            $ret = $this->activeModForGroup($groupid);
×
4419
        }
4420

4421
        #error_log("Notifs on for user #{$this->id} type $type ? $ret from " . var_export($notifs, TRUE));
4422
        return ($ret);
60✔
4423
    }
4424

4425
    public function getNotificationPayload($modtools)
4426
    {
4427
        # This gets a notification count/title/message for this user.
4428
        $notifcount = 0;
11✔
4429
        $title = '';
11✔
4430
        $message = NULL;
11✔
4431
        $chatids = [];
11✔
4432
        $route = NULL;
11✔
4433
        $category = NULL;
11✔
4434
        $threadId = NULL;
11✔
4435
        $image = NULL;
11✔
4436

4437
        if (!$modtools) {
11✔
4438
            # User notification.  We want to show a count of chat messages, or some of the message if there is just one.
4439
            $r = new ChatRoom($this->dbhr, $this->dbhm);
6✔
4440
            $unseen = $r->allUnseenForUser($this->id, [ChatRoom::TYPE_USER2USER, ChatRoom::TYPE_USER2MOD], $modtools);
6✔
4441
            $chatcount = count($unseen);
6✔
4442
            $total = $chatcount;
6✔
4443
            foreach ($unseen as $un) {
6✔
4444
                $chatids[] = $un['chatid'];
2✔
4445
            };
4446

4447
            #error_log("Chats with unseen " . var_export($chatids, TRUE));
4448
            $n = new Notifications($this->dbhr, $this->dbhm);
6✔
4449
            $notifcount = $n->countUnseen($this->id);
6✔
4450

4451
            if ($total ==  1) {
6✔
4452
                $r = new ChatRoom($this->dbhr, $this->dbhm, $unseen[0]['chatid']);
2✔
4453
                $atts = $r->getPublic($this);
2✔
4454
                $title = $atts['name'];
2✔
4455
                list($msgs, $users) = $r->getMessages(100, 0);
2✔
4456

4457
                if (count($msgs) > 0) {
2✔
4458
                    $message = Utils::presdef('message', $msgs[count($msgs) - 1], "You have a message");
2✔
4459

4460
                    # Decode emoji escape sequences to actual emojis for display.
4461
                    $message = Utils::decodeEmojis($message);
2✔
4462

4463
                    $message = strlen($message) > 256 ? (substr($message, 0, 256) . "...") : $message;
2✔
4464
                }
4465

4466
                $route = "/chats/" . $unseen[0]['chatid'];
2✔
4467
                $category = PushNotifications::CATEGORY_CHAT_MESSAGE;
2✔
4468
                $threadId = 'chat_' . $unseen[0]['chatid'];
2✔
4469
                $image = Utils::presdef('icon', $atts, NULL);
2✔
4470

4471
                if ($notifcount) {
2✔
4472
                    $total += $notifcount;
2✔
4473
                }
4474
            } else if ($total > 1) {
4✔
4475
                $title = "You have $total new messages";
×
4476
                $route = "/chats";
×
4477
                $category = PushNotifications::CATEGORY_CHAT_MESSAGE;
×
4478
                $threadId = 'chats';
×
4479

4480
                if ($notifcount) {
×
4481
                    $total += $notifcount;
×
4482
                    $title .= " and $notifcount notification" . ($notifcount == 1 ? '' : 's');
×
4483
                }
4484
            } else {
4485
                # Add in the notifications you see primarily from the newsfeed.
4486
                if ($notifcount) {
4✔
4487
                    $total += $notifcount;
4✔
4488
                    $ctx = NULL;
4✔
4489
                    $notifs = $n->get($this->id, $ctx);
4✔
4490
                    $title = $n->getNotifTitle($notifs);
4✔
4491

4492
                    if ($title) {
4✔
4493
                        $route = '/';
4✔
4494

4495
                        if (count($notifs) > 0) {
4✔
4496
                            # For newsfeed notifications sent a route to the right place.
4497
                            # Also set the appropriate notification category and thread ID.
4498
                            switch ($notifs[0]['type']) {
4✔
4499
                                case Notifications::TYPE_COMMENT_ON_YOUR_POST:
4✔
4500
                                    $route = '/chitchat/' . $notifs[0]['newsfeedid'];
1✔
4501
                                    $category = PushNotifications::CATEGORY_CHITCHAT_COMMENT;
1✔
4502
                                    $threadId = 'chitchat_' . $notifs[0]['newsfeedid'];
1✔
4503
                                    break;
1✔
4504
                                case Notifications::TYPE_COMMENT_ON_COMMENT:
4✔
4505
                                    $route = '/chitchat/' . $notifs[0]['newsfeedid'];
1✔
4506
                                    $category = PushNotifications::CATEGORY_CHITCHAT_REPLY;
1✔
4507
                                    $threadId = 'chitchat_' . $notifs[0]['newsfeedid'];
1✔
4508
                                    break;
1✔
4509
                                case Notifications::TYPE_LOVED_COMMENT:
4✔
4510
                                case Notifications::TYPE_LOVED_POST:
4✔
4511
                                    $route = '/chitchat/' . $notifs[0]['newsfeedid'];
2✔
4512
                                    $category = PushNotifications::CATEGORY_CHITCHAT_LOVED;
2✔
4513
                                    $threadId = 'chitchat_' . $notifs[0]['newsfeedid'];
2✔
4514
                                    break;
2✔
4515
                                case Notifications::TYPE_EXHORT:
2✔
4516
                                    $category = PushNotifications::CATEGORY_EXHORT;
1✔
4517
                                    $threadId = 'tips';
1✔
4518
                                    $message = Utils::presdef('text', $notifs[0], NULL);
1✔
4519
                                    if (Utils::presdef('url', $notifs[0], NULL)) {
1✔
4520
                                        $route = $notifs[0]['url'];
×
4521
                                    }
4522
                                    break;
6✔
4523
                            }
4524
                        }
4525
                    }
4526
                }
4527
            }
4528
        } else {
4529
            # ModTools notification.  We show the count of work + chats.
4530
            $r = new ChatRoom($this->dbhr, $this->dbhm);
7✔
4531
            $unseen = $r->allUnseenForUser($this->id, [ChatRoom::TYPE_MOD2MOD, ChatRoom::TYPE_USER2MOD], $modtools);
7✔
4532
            $chatcount = count($unseen);
7✔
4533

4534
            $work = $this->getWorkCounts();
7✔
4535
            $total = $work['total'] + $chatcount;
7✔
4536

4537
            // The order of these is important as the route will be the last matching.
4538
            $types = [
7✔
4539
                'pendingvolunteering' => [ 'volunteer op', 'volunteerops', '/modtools/volunteering' ],
7✔
4540
                'pendingevents' => [ 'event', 'events', '/modtools/communityevents' ],
7✔
4541
                'stories' => [ 'story', 'stories', '/modtools/members/stories' ],
7✔
4542
                'newsletterstories' => [ 'newsletter story', 'newsletter stories', '/modtools/members/newsletter' ],
7✔
4543
                'chatreview' => [ 'chat message to review', 'chat messages to review', '/modtools/chats/review' ],
7✔
4544
                'pendingadmins' => [ 'admin', 'admins', '/modtools/admins' ],
7✔
4545
                'spammembers' => [ 'member to review', 'members to review', '/modtools/members/review' ],
7✔
4546
                'relatedmembers' => [ 'related member to review', 'related members to review', '/modtools/members/related' ],
7✔
4547
                'editreview' => [ 'edit', 'edits', '/modtools/messages/edits' ],
7✔
4548
                'spam' => [ 'message to review', 'messages to review', '/modtools/messages/pending' ],
7✔
4549
                'pending' => [ 'pending message', 'pending messages', '/modtools/messages/pending' ]
7✔
4550
            ];
7✔
4551

4552
            $title = $chatcount ? ("$chatcount chat message" . ($chatcount != 1 ? 's' : '') . "\n") : '';
7✔
4553
            $route = NULL;
7✔
4554

4555
            foreach ($types as $type => $vals) {
7✔
4556
                if (Utils::presdef($type, $work, 0) > 0) {
7✔
4557
                    $title .= $work[$type] . ' ' . ($work[$type] != 1 ? $vals[1] : $vals[0] ) . "\n";
3✔
4558
                    $route = $vals[2];
3✔
4559
                }
4560
            }
4561

4562
            $title = $title == '' ? NULL : $title;
7✔
4563
        }
4564

4565

4566
        return ([$total, $chatcount, $notifcount, $title, $message, array_unique($chatids), $route, $category, $threadId, $image]);
11✔
4567
    }
4568

4569
    public function hasPermission($perm)
4570
    {
4571
        $perms = $this->user['permissions'];
41✔
4572
        return ($perms && stripos($perms, $perm) !== FALSE);
41✔
4573
    }
4574

4575
    public function sendIt($mailer, $message)
4576
    {
4577
        $mailer->send($message);
28✔
4578
    }
4579

4580
    public function thankDonation()
4581
    {
4582
        try {
4583
            $loader = new \Twig_Loader_Filesystem(IZNIK_BASE . '/mailtemplates/twig/donations');
1✔
4584
            $twig = new \Twig_Environment($loader);
1✔
4585
            list ($transport, $mailer) = Mail::getMailer();
1✔
4586

4587
            $message = \Swift_Message::newInstance()
1✔
4588
                ->setSubject("Thank you for supporting Freegle!")
1✔
4589
                ->setFrom(PAYPAL_THANKS_FROM)
1✔
4590
                ->setReplyTo(PAYPAL_THANKS_FROM)
1✔
4591
                ->setTo($this->getEmailPreferred())
1✔
4592
                ->setBody("Thank you for supporting Freegle!");
1✔
4593

4594
            Mail::addHeaders($this->dbhr, $this->dbhm, $message, Mail::THANK_DONATION, $this->id);
1✔
4595

4596
            $html = $twig->render('thank.html', [
1✔
4597
                'name' => $this->getName(),
1✔
4598
                'email' => $this->getEmailPreferred(),
1✔
4599
                'unsubscribe' => $this->loginLink(USER_SITE, $this->getId(), "/unsubscribe", NULL)
1✔
4600
            ]);
1✔
4601

4602
            # Add HTML in base-64 as default quoted-printable encoding leads to problems on
4603
            # Outlook.
4604
            $htmlPart = \Swift_MimePart::newInstance();
1✔
4605
            $htmlPart->setCharset('utf-8');
1✔
4606
            $htmlPart->setEncoder(new \Swift_Mime_ContentEncoder_Base64ContentEncoder);
1✔
4607
            $htmlPart->setContentType('text/html');
1✔
4608
            $htmlPart->setBody($html);
1✔
4609
            $message->attach($htmlPart);
1✔
4610

4611
            Mail::addHeaders($this->dbhr, $this->dbhm, $message, Mail::THANK_DONATION, $this->getId());
1✔
4612

4613
            $this->sendIt($mailer, $message);
1✔
4614
        } catch (\Exception $e) { error_log("Failed " . $e->getMessage()); };
×
4615
    }
4616

4617
    public function invite($email)
4618
    {
4619
        $ret = FALSE;
9✔
4620

4621
        # We can only invite logged in.
4622
        if ($this->id) {
9✔
4623
            # ...and only if we have spare.
4624
            if ($this->user['invitesleft'] > 0) {
9✔
4625
                # They might already be using us - but they might also have forgotten.  So allow that case.  However if
4626
                # they have actively declined a previous invitation we suppress this one.
4627
                $previous = $this->dbhr->preQuery("SELECT id FROM users_invitations WHERE email = ? AND outcome = ?;", [
9✔
4628
                    $email,
9✔
4629
                    User::INVITE_DECLINED
9✔
4630
                ]);
9✔
4631

4632
                if (count($previous) == 0) {
9✔
4633
                    # The table has a unique key on userid and email, so that means we can only invite the same person
4634
                    # once.  That avoids us pestering them.
4635
                    try {
4636
                        $this->dbhm->preExec("INSERT INTO users_invitations (userid, email) VALUES (?,?);", [
9✔
4637
                            $this->id,
9✔
4638
                            $email
9✔
4639
                        ]);
9✔
4640

4641
                        # We're ok to invite.
4642
                        $fromname = $this->getName();
9✔
4643
                        $frommail = $this->getEmailPreferred();
9✔
4644
                        $url = "https://" . USER_SITE . "/invite/" . $this->dbhm->lastInsertId();
9✔
4645

4646
                        list ($transport, $mailer) = Mail::getMailer();
9✔
4647
                        $message = \Swift_Message::newInstance()
9✔
4648
                            ->setSubject("$fromname has invited you to try Freegle!")
9✔
4649
                            ->setFrom([NOREPLY_ADDR => SITE_NAME])
9✔
4650
                            ->setReplyTo($frommail)
9✔
4651
                            ->setTo($email)
9✔
4652
                            ->setBody("$fromname ($email) thinks you might like Freegle, which helps you give and get things for free near you.  Click $url to try it.");
9✔
4653

4654
                        Mail::addHeaders($this->dbhr, $this->dbhm, $message, Mail::INVITATION);
9✔
4655

4656
                        $html = invite($fromname, $frommail, $url);
9✔
4657

4658
                        # Add HTML in base-64 as default quoted-printable encoding leads to problems on
4659
                        # Outlook.
4660
                        $htmlPart = \Swift_MimePart::newInstance();
9✔
4661
                        $htmlPart->setCharset('utf-8');
9✔
4662
                        $htmlPart->setEncoder(new \Swift_Mime_ContentEncoder_Base64ContentEncoder);
9✔
4663
                        $htmlPart->setContentType('text/html');
9✔
4664
                        $htmlPart->setBody($html);
9✔
4665
                        $message->attach($htmlPart);
9✔
4666

4667
                        $this->sendIt($mailer, $message);
9✔
4668
                        $ret = TRUE;
9✔
4669

4670
                        $this->dbhm->preExec("UPDATE users SET invitesleft = invitesleft - 1 WHERE id = ?;", [
9✔
4671
                            $this->id
9✔
4672
                        ]);
9✔
4673
                    } catch (\Exception $e) {
1✔
4674
                        # Probably a duplicate.
4675
                    }
4676
                }
4677
            }
4678
        }
4679

4680
        return ($ret);
9✔
4681
    }
4682

4683
    public function inviteOutcome($id, $outcome)
4684
    {
4685
        $invites = $this->dbhm->preQuery("SELECT * FROM users_invitations WHERE id = ?;", [
1✔
4686
            $id
1✔
4687
        ]);
1✔
4688

4689
        foreach ($invites as $invite) {
1✔
4690
            if ($invite['outcome'] == User::INVITE_PENDING) {
1✔
4691
                $this->dbhm->preExec("UPDATE users_invitations SET outcome = ?, outcometimestamp = NOW() WHERE id = ?;", [
1✔
4692
                    $outcome,
1✔
4693
                    $id
1✔
4694
                ]);
1✔
4695

4696
                if ($outcome == User::INVITE_ACCEPTED) {
1✔
4697
                    # Give the sender two more invites.  This means that if their invitations are unsuccessful, they will
4698
                    # stall, but if they do ok, they won't.  This isn't perfect - someone could fake up emails and do
4699
                    # successful invitations that way.
4700
                    $this->dbhm->preExec("UPDATE users SET invitesleft = invitesleft + 2 WHERE id = ?;", [
1✔
4701
                        $invite['userid']
1✔
4702
                    ]);
1✔
4703
                }
4704
            }
4705
        }
4706
    }
4707

4708
    public function listInvitations($since = "30 days ago")
4709
    {
4710
        $ret = [];
8✔
4711

4712
        # Don't show old invitations - unaccepted ones could languish for ages.
4713
        $mysqltime = date('Y-m-d', strtotime($since));
8✔
4714
        $invites = $this->dbhr->preQuery("SELECT id, email, date, outcome, outcometimestamp FROM users_invitations WHERE userid = ? AND date > '$mysqltime';", [
8✔
4715
            $this->id
8✔
4716
        ]);
8✔
4717

4718
        foreach ($invites as $invite) {
8✔
4719
            # Check if this email is now on the platform.
4720
            $invite['date'] = Utils::ISODate($invite['date']);
7✔
4721
            $invite['outcometimestamp'] = $invite['outcometimestamp'] ? Utils::ISODate($invite['outcometimestamp']) : NULL;
7✔
4722
            $ret[] = $invite;
7✔
4723
        }
4724

4725
        return ($ret);
8✔
4726
    }
4727

4728
    public function getLatLng($usedef = TRUE, $usegroup = TRUE, $blur = Utils::BLUR_NONE)
4729
    {
4730
        $ret = [ 0, 0, NULL ];
145✔
4731

4732
        if ($this->id) {
145✔
4733
            $locs = $this->getLatLngs([ $this->user ], $usedef, $usegroup, FALSE, [ $this->user ]);
145✔
4734
            $loc = $locs[$this->id];
145✔
4735

4736
            if ($loc) {
145✔
4737
                if ($blur && ($loc['lat'] || $loc['lng'])) {
143✔
4738
                    list ($loc['lat'], $loc['lng']) = Utils::blur($loc['lat'], $loc['lng'], $blur);
4✔
4739
                }
4740

4741
                $ret = [ $loc['lat'], $loc['lng'], Utils::presdef('loc', $loc, NULL) ];
143✔
4742
            }
4743
        }
4744

4745
        return $ret;
145✔
4746
    }
4747

4748
    public function getPublicLocations(&$users, $atts = NULL)
4749
    {
4750
        $idsleft = [];
107✔
4751
        
4752
        foreach ($users as $userid => $user) {
107✔
4753
            if (!Utils::pres('info', $user) || !Utils::pres('publiclocation', $user['info'])) {
107✔
4754
                $idsleft[] = $userid;
107✔
4755
            }
4756
        }
4757
        
4758
        $areas = NULL;
107✔
4759
        $membs = NULL;
107✔
4760

4761
        if ($idsleft && count($idsleft)) {
107✔
4762
            # First try to get the location from settings or last location.
4763
            $atts = $atts ? $atts : $this->dbhr->preQuery("SELECT id, settings, lastlocation FROM users WHERE id in (" . implode(',', $idsleft) . ");", NULL, FALSE, FALSE);
107✔
4764

4765
            foreach ($atts as $att) {
107✔
4766
                $loc = NULL;
107✔
4767
                $grp = NULL;
107✔
4768

4769
                $aid = NULL;
107✔
4770
                $lid = NULL;
107✔
4771
                $lat = NULL;
107✔
4772
                $lng = NULL;
107✔
4773

4774
                # Default to nowhere.
4775
                $users[$att['id']]['info']['publiclocation'] = [
107✔
4776
                    'display' => '',
107✔
4777
                    'location' => NULL,
107✔
4778
                    'groupname' => NULL
107✔
4779
                ];
107✔
4780

4781
                if (Utils::pres('settings', $att)) {
107✔
4782
                    $settings = $att['settings'];
22✔
4783
                    $settings = json_decode($settings, TRUE);
22✔
4784

4785
                    if (Utils::pres('mylocation', $settings) && Utils::pres('area', $settings['mylocation'])) {
22✔
4786
                        $loc = $settings['mylocation']['area']['name'];
7✔
4787
                        $lid = $settings['mylocation']['id'];
7✔
4788
                        $lat = $settings['mylocation']['lat'];
7✔
4789
                        $lng = $settings['mylocation']['lng'];
7✔
4790
                    }
4791
                }
4792

4793
                if (!$loc) {
107✔
4794
                    # Get the name of the last area we used.
4795
                    if (is_null($areas)) {
100✔
4796
                        $areas = $this->dbhr->preQuery("SELECT l2.id, l2.name, l2.lat, l2.lng, users.id AS userid FROM locations l1 
100✔
4797
                            INNER JOIN users ON users.lastlocation = l1.id
4798
                            INNER JOIN locations l2 ON l2.id = l1.areaid
4799
                            WHERE users.id IN (" . implode(',', $idsleft) . ");", NULL, FALSE, FALSE);
100✔
4800
                    }
4801

4802
                    foreach ($areas as $area) {
100✔
4803
                        if ($att['id'] ==  $area['userid']) {
24✔
4804
                            $loc = $area['name'];
24✔
4805
                            $lid = $area['id'];
24✔
4806
                            $lat = $area['lat'];
24✔
4807
                            $lng = $area['lng'];
24✔
4808
                        }
4809
                    }
4810
                }
4811

4812
                if (!$lid) {
107✔
4813
                    # Find the group of which we are a member which is closest to our location.  We do this because generally
4814
                    # the number of groups we're in is small and therefore this will be quick, whereas the groupsNear call is
4815
                    # fairly slow.
4816
                    $closestdist = PHP_INT_MAX;
100✔
4817
                    $closestname = NULL;
100✔
4818

4819
                    # Get all the memberships.
4820
                    if (!$membs) {
100✔
4821
                        $sql = "SELECT memberships.userid, groups.id, groups.nameshort, groups.namefull, groups.lat, groups.lng FROM `groups` INNER JOIN memberships ON groups.id = memberships.groupid WHERE memberships.userid IN (" . implode(
100✔
4822
                                ',',
100✔
4823
                                $idsleft
100✔
4824
                            ) . ") ORDER BY added ASC;";
100✔
4825
                        $membs = $this->dbhr->preQuery($sql);
100✔
4826
                    }
4827

4828
                    foreach ($membs as $memb) {
100✔
4829
                        if ($memb['userid'] == $att['id']) {
89✔
4830
                            $dist = \GreatCircle::getDistance($lat, $lng, $memb['lat'], $memb['lng']);
89✔
4831

4832
                            if ($dist < $closestdist) {
89✔
4833
                                $closestdist = $dist;
89✔
4834
                                $closestname = $memb['namefull'] ? $memb['namefull'] : $memb['nameshort'];
89✔
4835
                            }
4836
                        }
4837
                    }
4838

4839
                    if (!is_null($closestname)) {
100✔
4840
                        $grp = $closestname;
89✔
4841

4842
                        # The location name might be in the group name, in which case just use the group.
4843
                        $loc = stripos($grp, $loc) !== FALSE ? NULL : $loc;
89✔
4844
                    }
4845
                }
4846

4847
                if ($loc) {
107✔
4848
                    $display = $loc ? ($loc . ($grp ? ", $grp" : "")) : ($grp ? $grp : '');
31✔
4849

4850
                    $users[$att['id']]['info']['publiclocation'] = [
31✔
4851
                        'display' => $display,
31✔
4852
                        'location' => $loc,
31✔
4853
                        'groupname' => $grp
31✔
4854
                    ];
31✔
4855

4856
                    $idsleft = array_filter($idsleft, function($val) use ($att) {
31✔
4857
                        return($val != $att['id']);
31✔
4858
                    });
31✔
4859
                }
4860
            }
4861

4862
            if (count($idsleft) > 0) {
107✔
4863
                # We have some left which don't have explicit postcodes.  Try for a group name.
4864
                #
4865
                # First check the group we used most recently.
4866
                #error_log("Look for group name only for {$att['id']}");
4867
                $found = [];
100✔
4868
                foreach ($idsleft as $userid) {
100✔
4869
                    $messages = $this->dbhr->preQuery("SELECT subject FROM messages INNER JOIN messages_groups ON messages_groups.msgid = messages.id WHERE fromuser = ? ORDER BY messages.arrival DESC LIMIT 1;", [
100✔
4870
                        $userid
100✔
4871
                    ]);
100✔
4872

4873
                    foreach ($messages as $msg) {
100✔
4874
                        list ($type, $item, $location) = Message::parseSubject($msg['subject']);
63✔
4875

4876
                        if ($item) {
63✔
4877
                            $grp = $location;
44✔
4878

4879
                            // Handle some misformed locations which end up with spurious brackets.
4880
                            $grp = preg_replace('/\(|\)/', '', $grp);
44✔
4881

4882
                            $users[$userid]['info']['publiclocation'] = [
44✔
4883
                                'display' => $grp,
44✔
4884
                                'location' => NULL,
44✔
4885
                                'groupname' => $grp
44✔
4886
                            ];
44✔
4887

4888
                            $found[] = $userid;
44✔
4889
                        }
4890
                    }
4891
                }
4892

4893
                $idsleft = array_diff($idsleft, $found);
100✔
4894
                
4895
                # Now check just membership.
4896
                if (count($idsleft)) {
100✔
4897
                    if (!$membs) {
66✔
4898
                        $sql = "SELECT memberships.userid, groups.id, groups.nameshort, groups.namefull, groups.lat, groups.lng FROM `groups` INNER JOIN memberships ON groups.id = memberships.groupid WHERE memberships.userid IN (" . implode(
22✔
4899
                                ',',
22✔
4900
                                $idsleft
22✔
4901
                            ) . ") ORDER BY added ASC;";
22✔
4902
                        $membs = $this->dbhr->preQuery($sql);
22✔
4903
                    }
4904
                    
4905
                    foreach ($idsleft as $userid) {
66✔
4906
                        # Now check the group we joined most recently.
4907
                        foreach ($membs as $memb) {
66✔
4908
                            if ($memb['userid'] == $userid) {
48✔
4909
                                $grp = $memb['namefull'] ? $memb['namefull'] : $memb['nameshort'];
48✔
4910

4911
                                $users[$userid]['info']['publiclocation'] = [
48✔
4912
                                    'display' => $grp,
48✔
4913
                                    'location' => NULL,
48✔
4914
                                    'groupname' => $grp
48✔
4915
                                ];
48✔
4916
                            }
4917
                        }
4918
                    }
4919
                }
4920
            }
4921
        }
4922
    }
4923

4924
    public function getLatLngs($users, $usedef = TRUE, $usegroup = TRUE, $needgroup = FALSE, $atts = NULL, $blur = NULL)
4925
    {
4926
        $userids = array_filter(array_column($users, 'id'));
146✔
4927
        $ret = [];
146✔
4928

4929
        if ($userids && count($userids)) {
146✔
4930
            $atts = $atts ? $atts : $this->dbhr->preQuery("SELECT id, settings, lastlocation FROM users WHERE id in (" . implode(',', $userids) . ");", NULL, FALSE, FALSE);
146✔
4931

4932
            foreach ($atts as $att) {
146✔
4933
                $lat = NULL;
146✔
4934
                $lng = NULL;
146✔
4935
                $loc = NULL;
146✔
4936

4937
                if (Utils::pres('settings', $att)) {
146✔
4938
                    $settings = $att['settings'];
32✔
4939
                    $settings = json_decode($settings, TRUE);
32✔
4940

4941
                    if (Utils::pres('mylocation', $settings)) {
32✔
4942
                        $lat = $settings['mylocation']['lat'];
30✔
4943
                        $lng = $settings['mylocation']['lng'];
30✔
4944
                        $loc = Utils::presdef('name', $settings['mylocation'], NULL);
30✔
4945
                        #error_log("Got from mylocation $lat, $lng, $loc");
4946
                    }
4947
                }
4948

4949
                if (is_null($lat)) {
146✔
4950
                    $lid = $att['lastlocation'];
129✔
4951

4952
                    if ($lid) {
129✔
4953
                        $l = new Location($this->dbhr, $this->dbhm, $lid);
23✔
4954
                        $lat = $l->getPrivate('lat');
23✔
4955
                        $lng = $l->getPrivate('lng');
23✔
4956
                        $loc = $l->getPrivate('name');
23✔
4957
                        #error_log("Got from last location $lat, $lng, $loc");
4958
                    }
4959
                }
4960

4961
                if (!is_null($lat)) {
146✔
4962
                    $ret[$att['id']] = [
49✔
4963
                        'lat' => $lat,
49✔
4964
                        'lng' => $lng,
49✔
4965
                        'loc' => $loc,
49✔
4966
                    ];
49✔
4967

4968
                    $userids = array_filter($userids, function($id) use ($att) {
49✔
4969
                        return $id != $att['id'];
49✔
4970
                    });
49✔
4971
                }
4972
            }
4973
        }
4974

4975
        if ($userids && count($userids) && $usegroup) {
146✔
4976
            # Still some we haven't handled.  Get the last message posted on a group with a location, if any.
4977
            $membs = $this->dbhr->preQuery("SELECT fromuser AS userid, lat, lng FROM messages WHERE fromuser IN (" . implode(',', $userids) . ") AND lat IS NOT NULL AND lng IS NOT NULL ORDER BY arrival ASC;", NULL, FALSE, FALSE);
122✔
4978
            foreach ($membs as $memb) {
122✔
4979
                $ret[$memb['userid']] = [
3✔
4980
                    'lat' => $memb['lat'],
3✔
4981
                    'lng' => $memb['lng']
3✔
4982
                ];
3✔
4983

4984
                #error_log("Got from last message posted {$memb['lat']}, {$memb['lng']}");
4985

4986
                $userids = array_filter($userids, function($id) use ($memb) {
3✔
4987
                    return $id != $memb['userid'];
3✔
4988
                });
3✔
4989
            }
4990
        }
4991

4992
        if ($userids && count($userids) && $usegroup) {
146✔
4993
            # Still some we haven't handled.  Get the memberships.  Logic will choose most recently joined.
4994
            $membs = $this->dbhr->preQuery("SELECT userid, lat, lng, nameshort, namefull FROM `groups` INNER JOIN memberships ON memberships.groupid = groups.id WHERE userid IN (" . implode(',', $userids) . ") ORDER BY added ASC;", NULL, FALSE, FALSE);
120✔
4995
            foreach ($membs as $memb) {
120✔
4996
                $ret[$memb['userid']] = [
104✔
4997
                    'lat' => $memb['lat'],
104✔
4998
                    'lng' => $memb['lng'],
104✔
4999
                    'group' => Utils::presdef('namefull', $memb, $memb['nameshort'])
104✔
5000
                ];
104✔
5001

5002
                #error_log("Got from membership {$memb['lat']}, {$memb['lng']}, " . Utils::presdef('namefull', $memb, $memb['nameshort']));
5003

5004
                $userids = array_filter($userids, function($id) use ($memb) {
104✔
5005
                    return $id != $memb['userid'];
104✔
5006
                });
104✔
5007
            }
5008
        }
5009

5010
        if ($userids && count($userids)) {
146✔
5011
            # Still some we haven't handled.
5012
            foreach ($userids as $userid) {
24✔
5013
                if ($usedef) {
24✔
5014
                    $ret[$userid] = [
19✔
5015
                        'lat' => 53.9450,
19✔
5016
                        'lng' => -2.5209
19✔
5017
                    ];
19✔
5018
                } else {
5019
                    $ret[$userid] = NULL;
15✔
5020
                }
5021
            }
5022
        }
5023

5024
        if ($needgroup) {
146✔
5025
            # Get a group name.
5026
            $membs = $this->dbhr->preQuery("SELECT userid, nameshort, namefull FROM `groups` INNER JOIN memberships ON memberships.groupid = groups.id WHERE userid IN (" . implode(',', array_filter(array_column($users, 'id'))) . ") ORDER BY added ASC;", NULL, FALSE, FALSE);
7✔
5027
            foreach ($membs as $memb) {
7✔
5028
                $ret[$memb['userid']]['group'] = Utils::presdef('namefull', $memb, $memb['nameshort']);
7✔
5029
            }
5030
        }
5031

5032
        if ($blur) {
146✔
5033
            foreach ($ret as &$memb) {
7✔
5034
                if ($memb['lat'] || $memb['lng']) {
7✔
5035
                    list ($memb['lat'], $memb['lng']) = Utils::blur($memb['lat'], $memb['lng'], $blur);
7✔
5036
                }
5037
            }
5038
        }
5039

5040
        return ($ret);
146✔
5041
    }
5042

5043
    public function isFreegleMod()
5044
    {
5045
        $ret = FALSE;
172✔
5046

5047
        $this->cacheMemberships();
172✔
5048

5049
        foreach ($this->memberships as $mem) {
172✔
5050
            if ($mem['type'] == Group::GROUP_FREEGLE && ($mem['role'] == User::ROLE_OWNER || $mem['role'] == User::ROLE_MODERATOR)) {
142✔
5051
                $ret = TRUE;
42✔
5052
            }
5053
        }
5054

5055
        return ($ret);
172✔
5056
    }
5057

5058
    public function getKudos($id = NULL)
5059
    {
5060
        $id = $id ? $id : $this->id;
1✔
5061
        $kudos = [
1✔
5062
            'userid' => $id,
1✔
5063
            'posts' => 0,
1✔
5064
            'chats' => 0,
1✔
5065
            'newsfeed' => 0,
1✔
5066
            'events' => 0,
1✔
5067
            'vols' => 0,
1✔
5068
            'facebook' => 0,
1✔
5069
            'platform' => 0,
1✔
5070
            'kudos' => 0,
1✔
5071
        ];
1✔
5072

5073
        $kudi = $this->dbhr->preQuery("SELECT * FROM users_kudos WHERE userid = ?;", [
1✔
5074
            $id
1✔
5075
        ]);
1✔
5076

5077
        foreach ($kudi as $k) {
1✔
5078
            $kudos = $k;
1✔
5079
        }
5080

5081
        return ($kudos);
1✔
5082
    }
5083

5084
    public function updateKudos($id = NULL, $force = FALSE)
5085
    {
5086
        $current = $this->getKudos($id);
1✔
5087

5088
        # Only update if we don't have one or it's older than a day.  This avoids repeatedly updating the entry
5089
        # for the same user in some bulk operations.
5090
        if (!Utils::pres('timestamp', $current) || (time() - strtotime($current['timestamp']) > 24 * 60 * 60)) {
1✔
5091
            # We analyse a user's activity and assign them a level.
5092
            #
5093
            # Only interested in activity in the last year.
5094
            $id = $id ? $id : $this->id;
1✔
5095
            $start = date('Y-m-d', strtotime("365 days ago"));
1✔
5096

5097
            # First, the number of months in which they have posted.
5098
            $posts = $this->dbhr->preQuery("SELECT COUNT(DISTINCT(CONCAT(YEAR(date), '-', MONTH(date)))) AS count FROM messages WHERE fromuser = ? AND date >= '$start';", [
1✔
5099
                $id
1✔
5100
            ])[0]['count'];
1✔
5101

5102
            # Ditto communicated with people.
5103
            $chats = $this->dbhr->preQuery("SELECT COUNT(DISTINCT(CONCAT(YEAR(date), '-', MONTH(date)))) AS count FROM chat_messages WHERE userid = ? AND date >= '$start';", [
1✔
5104
                $id
1✔
5105
            ])[0]['count'];
1✔
5106

5107
            # Newsfeed posts
5108
            $newsfeed = $this->dbhr->preQuery("SELECT COUNT(DISTINCT(CONCAT(YEAR(timestamp), '-', MONTH(timestamp)))) AS count FROM newsfeed WHERE userid = ? AND added >= '$start';", [
1✔
5109
                $id
1✔
5110
            ])[0]['count'];
1✔
5111

5112
            # Events
5113
            $events = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM communityevents WHERE userid = ? AND added >= '$start';", [
1✔
5114
                $id
1✔
5115
            ])[0]['count'];
1✔
5116

5117
            # Volunteering
5118
            $vols = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM volunteering WHERE userid = ? AND added >= '$start';", [
1✔
5119
                $id
1✔
5120
            ])[0]['count'];
1✔
5121

5122
            # Do they have a Facebook login?
5123
            $facebook = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM users_logins WHERE userid = ? AND type = ?", [
1✔
5124
                    $id,
1✔
5125
                    User::LOGIN_FACEBOOK
1✔
5126
                ])[0]['count'] > 0;
1✔
5127

5128
            # Have they posted using the platform?
5129
            $platform = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM messages WHERE fromuser = ? AND arrival >= '$start' AND sourceheader = ?;", [
1✔
5130
                    $id,
1✔
5131
                    Message::PLATFORM
1✔
5132
                ])[0]['count'] > 0;
1✔
5133

5134
            $kudos = $posts + $chats + $newsfeed + $events + $vols;
1✔
5135

5136
            if ($kudos > 0 || $force) {
1✔
5137
                # No sense in creating entries which are blank or the same.
5138
                $current = $this->getKudos($id);
1✔
5139

5140
                if ($current['kudos'] != $kudos || $force) {
1✔
5141
                    $this->dbhm->preExec("REPLACE INTO users_kudos (userid, kudos, posts, chats, newsfeed, events, vols, facebook, platform) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);", [
1✔
5142
                        $id,
1✔
5143
                        $kudos,
1✔
5144
                        $posts,
1✔
5145
                        $chats,
1✔
5146
                        $newsfeed,
1✔
5147
                        $events,
1✔
5148
                        $vols,
1✔
5149
                        $facebook,
1✔
5150
                        $platform
1✔
5151
                    ], FALSE);
1✔
5152
                }
5153
            }
5154
        }
5155
    }
5156

5157
    public function topKudos($gid, $limit = 10)
5158
    {
5159
        $limit = intval($limit);
1✔
5160

5161
        $kudos = $this->dbhr->preQuery("SELECT users_kudos.* FROM users_kudos INNER JOIN users ON users.id = users_kudos.userid INNER JOIN memberships ON memberships.userid = users_kudos.userid AND memberships.groupid = ? WHERE memberships.role = ? ORDER BY kudos DESC LIMIT $limit;", [
1✔
5162
            $gid,
1✔
5163
            User::ROLE_MEMBER
1✔
5164
        ]);
1✔
5165

5166
        $ret = [];
1✔
5167

5168
        foreach ($kudos as $k) {
1✔
5169
            $u = new User($this->dbhr, $this->dbhm, $k['userid']);
1✔
5170
            $atts = $u->getPublic();
1✔
5171
            $atts['email'] = $u->getEmailPreferred();
1✔
5172

5173
            $thisone = [
1✔
5174
                'user' => $atts,
1✔
5175
                'kudos' => $k
1✔
5176
            ];
1✔
5177

5178
            $ret[] = $thisone;
1✔
5179
        }
5180

5181
        return ($ret);
1✔
5182
    }
5183

5184
    public function possibleMods($gid, $limit = 10)
5185
    {
5186
        # We look for users who are not mods with top kudos who also:
5187
        # - active in last 60 days
5188
        # - not bouncing
5189
        # - using a location which is in the group area
5190
        # - have posted with the platform, as we don't want loyal users of TN or Yahoo.
5191
        # - have a Facebook login, as they are more likely to do publicity.
5192
        $limit = intval($limit);
1✔
5193
        $start = date('Y-m-d', strtotime("60 days ago"));
1✔
5194
        $sql = "SELECT users_kudos.* FROM users_kudos INNER JOIN users ON users.id = users_kudos.userid INNER JOIN memberships ON memberships.userid = users_kudos.userid AND memberships.groupid = ? INNER JOIN `groups` ON groups.id = memberships.groupid INNER JOIN locations_spatial ON users.lastlocation = locations_spatial.locationid WHERE memberships.role = ? AND users_kudos.platform = 1 AND users_kudos.facebook = 1 AND ST_Contains(ST_GeomFromText(groups.poly, {$this->dbhr->SRID()}), locations_spatial.geometry) AND bouncing = 0 AND lastaccess >= '$start' ORDER BY kudos DESC LIMIT $limit;";
1✔
5195
        $kudos = $this->dbhr->preQuery($sql, [
1✔
5196
            $gid,
1✔
5197
            User::ROLE_MEMBER
1✔
5198
        ]);
1✔
5199

5200
        $ret = [];
1✔
5201

5202
        foreach ($kudos as $k) {
1✔
5203
            $u = new User($this->dbhr, $this->dbhm, $k['userid']);
1✔
5204
            $atts = $u->getPublic();
1✔
5205
            $atts['email'] = $u->getEmailPreferred();
1✔
5206

5207
            $thisone = [
1✔
5208
                'user' => $atts,
1✔
5209
                'kudos' => $k
1✔
5210
            ];
1✔
5211

5212
            $ret[] = $thisone;
1✔
5213
        }
5214

5215
        return ($ret);
1✔
5216
    }
5217

5218
    public function requestExport($sync = FALSE)
5219
    {
5220
        $tag = Utils::randstr(64);
8✔
5221

5222
        # Flag sync ones as started to avoid window with background thread.
5223
        $sync = $sync ? "NOW()" : "NULL";
8✔
5224
        $this->dbhm->preExec("INSERT INTO users_exports (userid, tag, started) VALUES (?, ?, $sync);", [
8✔
5225
            $this->id,
8✔
5226
            $tag
8✔
5227
        ]);
8✔
5228

5229
        return ([$this->dbhm->lastInsertId(), $tag]);
8✔
5230
    }
5231

5232
    public function export($exportid, $tag)
5233
    {
5234
        $this->dbhm->preExec("UPDATE users_exports SET started = NOW() WHERE id = ? AND tag = ?;", [
7✔
5235
            $exportid,
7✔
5236
            $tag
7✔
5237
        ]);
7✔
5238

5239
        # For GDPR we support the ability for a user to export the data we hold about them.  Key points about this:
5240
        #
5241
        # - It needs to be at a high level of abstraction and understandable by the user, not just a cryptic data
5242
        #   dump.
5243
        # - It needs to include data provided by the user and data observed about the user, but not profiling
5244
        #   or categorisation based on that data.  This means that (for example) we need to return which
5245
        #   groups they have joined, but not whether joining those groups has flagged them up as a potential
5246
        #   spammer.
5247
        $ret = [];
7✔
5248
        error_log("...basic info");
7✔
5249

5250
        # Data in user table.
5251
        $d = [];
7✔
5252
        $d['Our_internal_ID_for_you'] = $this->getPrivate('id');
7✔
5253
        $d['Your_full_name'] = $this->getPrivate('fullname');
7✔
5254
        $d['Your_first_name'] = $this->getPrivate('firstname');
7✔
5255
        $d['Your_last_name'] = $this->getPrivate('lastname');
7✔
5256
        $d['Your_Yahoo_ID'] = $this->getPrivate('yahooid');
7✔
5257
        $d['Your_role_on_the_system'] = $this->getPrivate('systemrole');
7✔
5258
        $d['When_you_joined_the_site'] = Utils::ISODate($this->getPrivate('added'));
7✔
5259
        $d['When_you_last_accessed_the_site'] = Utils::ISODate($this->getPrivate('lastaccess'));
7✔
5260
        $d['When_we_last_checked_for_relevant_posts_for_you'] = Utils::ISODate($this->getPrivate('lastrelevantcheck'));
7✔
5261
        $d['Whether_your_email_is_bouncing'] = $this->getPrivate('bouncing') ? 'Yes' : 'No';
7✔
5262
        $d['Permissions_you_have_on_the_site'] = $this->getPrivate('permissions');
7✔
5263
        $d['Number_of_remaining_invitations_you_can_send_to_other_people'] = $this->getPrivate('invitesleft');
7✔
5264

5265
        $lastlocation = $this->user['lastlocation'];
7✔
5266

5267
        if ($lastlocation) {
7✔
5268
            $l = new Location($this->dbhr, $this->dbhm, $lastlocation);
×
5269
            $d['Last_location_you_posted_from'] = $l->getPrivate('name') . " (" . $l->getPrivate('lat') . ', ' . $l->getPrivate('lng') . ')';
×
5270
        }
5271

5272
        $settings = $this->getPrivate('settings');
7✔
5273

5274
        if ($settings) {
7✔
5275
            $settings = json_decode($settings, TRUE);
7✔
5276

5277
            $location = Utils::presdef('id', Utils::presdef('mylocation', $settings, []), NULL);
7✔
5278

5279
            if ($location) {
7✔
5280
                $l = new Location($this->dbhr, $this->dbhm, $location);
6✔
5281
                $d['Last_location_you_entered'] = $l->getPrivate('name') . ' (' . $l->getPrivate('lat') . ', ' . $l->getPrivate('lng') . ')';
6✔
5282
            }
5283

5284
            $notifications = Utils::pres('notifications', $settings);
7✔
5285

5286
            $d['Notifications']['Send_email_notifications_for_chat_messages'] = Utils::presdef('email', $notifications, TRUE) ? 'Yes' : 'No';
7✔
5287
            $d['Notifications']['Send_email_notifications_of_chat_messages_you_send'] = Utils::presdef('emailmine', $notifications, TRUE) ? 'Yes' : 'No';
7✔
5288
            $d['Notifications']['Send_push_notifications_to_web_browsers'] = Utils::presdef('push', $notifications, TRUE) ? 'Yes' : 'No';
7✔
5289
            $d['Notifications']['Send_Facebook_notifications'] = Utils::presdef('facebook', $notifications, TRUE) ? 'Yes' : 'No';
7✔
5290
            $d['Notifications']['Send_emails_about_notifications_on_the_site'] = Utils::presdef('notificationmails', $notifications, TRUE) ? 'Yes' : 'No';
7✔
5291

5292
            $d['Hide_profile_picture'] = Utils::presdef('useprofile', $settings, TRUE) ? 'Yes' : 'No';
7✔
5293

5294
            if ($this->isModerator()) {
7✔
5295
                $d['Show_members_that_you_are_a_moderator'] = Utils::pres('showmod', $settings) ? 'Yes' : 'No';
1✔
5296

5297
                switch (Utils::presdef('modnotifs', $settings, 4)) {
1✔
5298
                    case 24:
1✔
5299
                        $d['Send_notifications_of_active_mod_work'] = 'After 24 hours';
×
5300
                        break;
×
5301
                    case 12:
1✔
5302
                        $d['Send_notifications_of_active_mod_work'] = 'After 12 hours';
×
5303
                        break;
×
5304
                    case 4:
1✔
5305
                        $d['Send_notifications_of_active_mod_work'] = 'After 4 hours';
1✔
5306
                        break;
1✔
5307
                    case 2:
×
5308
                        $d['Send_notifications_of_active_mod_work'] = 'After 2 hours';
×
5309
                        break;
×
5310
                    case 1:
×
5311
                        $d['Send_notifications_of_active_mod_work'] = 'After 1 hours';
×
5312
                        break;
×
5313
                    case 0:
×
5314
                        $d['Send_notifications_of_active_mod_work'] = 'Immediately';
×
5315
                        break;
×
5316
                    case -1:
5317
                        $d['Send_notifications_of_active_mod_work'] = 'Never';
×
5318
                        break;
×
5319
                }
5320

5321
                switch (Utils::presdef('backupmodnotifs', $settings, 12)) {
1✔
5322
                    case 24:
1✔
5323
                        $d['Send_notifications_of_backup_mod_work'] = 'After 24 hours';
×
5324
                        break;
×
5325
                    case 12:
1✔
5326
                        $d['Send_notifications_of_backup_mod_work'] = 'After 12 hours';
1✔
5327
                        break;
1✔
5328
                    case 4:
×
5329
                        $d['Send_notifications_of_backup_mod_work'] = 'After 4 hours';
×
5330
                        break;
×
5331
                    case 2:
×
5332
                        $d['Send_notifications_of_backup_mod_work'] = 'After 2 hours';
×
5333
                        break;
×
5334
                    case 1:
×
5335
                        $d['Send_notifications_of_backup_mod_work'] = 'After 1 hours';
×
5336
                        break;
×
5337
                    case 0:
×
5338
                        $d['Send_notifications_of_backup_mod_work'] = 'Immediately';
×
5339
                        break;
×
5340
                    case -1:
5341
                        $d['Send_notifications_of_backup_mod_work'] = 'Never';
×
5342
                        break;
×
5343
                }
5344

5345
                $d['Show_members_that_you_are_a_moderator'] = Utils::presdef('showmod', $settings, TRUE) ? 'Yes' : 'No';
1✔
5346
            }
5347
        }
5348

5349
        # Invitations.  Only show what we sent; the outcome is not this user's business.
5350
        error_log("...invitations");
7✔
5351
        $invites = $this->listInvitations("1970-01-01");
7✔
5352
        $d['invitations'] = [];
7✔
5353

5354
        foreach ($invites as $invite) {
7✔
5355
            $d['invitations'][] = [
6✔
5356
                'email' => $invite['email'],
6✔
5357
                'date' => Utils::ISODate($invite['date'])
6✔
5358
            ];
6✔
5359
        }
5360

5361
        error_log("...emails");
7✔
5362
        $d['emails'] = $this->getEmails();
7✔
5363

5364
        foreach ($d['emails'] as &$email) {
7✔
5365
            $email['added'] = Utils::ISODate($email['added']);
1✔
5366

5367
            if ($email['validated']) {
1✔
5368
                $email['validated'] = Utils::ISODate($email['validated']);
×
5369
            }
5370
        }
5371

5372
        error_log("...logins");
7✔
5373
        $d['logins'] = $this->dbhr->preQuery("SELECT type, uid, added, lastaccess FROM users_logins WHERE userid = ?;", [
7✔
5374
            $this->id
7✔
5375
        ]);
7✔
5376

5377
        foreach ($d['logins'] as &$dd) {
7✔
5378
            $dd['added'] = Utils::ISODate($dd['added']);
7✔
5379
            $dd['lastaccess'] = Utils::ISODate($dd['lastaccess']);
7✔
5380
        }
5381

5382
        error_log("...memberships");
7✔
5383
        $d['memberships'] = $this->getMemberships();
7✔
5384

5385
        error_log("...memberships history");
7✔
5386
        $sql = "SELECT DISTINCT memberships_history.*, groups.nameshort, groups.namefull FROM memberships_history INNER JOIN `groups` ON memberships_history.groupid = groups.id WHERE userid = ? ORDER BY added ASC;";
7✔
5387
        $membs = $this->dbhr->preQuery($sql, [$this->id]);
7✔
5388
        foreach ($membs as &$memb) {
7✔
5389
            $name = $memb['namefull'] ? $memb['namefull'] : $memb['nameshort'];
7✔
5390
            $memb['namedisplay'] = $name;
7✔
5391
            $memb['added'] = Utils::ISODate($memb['added']);
7✔
5392
        }
5393

5394
        $d['membershipshistory'] = $membs;
7✔
5395

5396
        error_log("...searches");
7✔
5397
        $d['searches'] = $this->dbhr->preQuery("SELECT search_history.date, search_history.term, locations.name AS location FROM search_history LEFT JOIN locations ON search_history.locationid = locations.id WHERE search_history.userid = ? ORDER BY search_history.date ASC;", [
7✔
5398
            $this->id
7✔
5399
        ]);
7✔
5400

5401
        foreach ($d['searches'] as &$s) {
7✔
5402
            $s['date'] = Utils::ISODate($s['date']);
×
5403
        }
5404

5405
        error_log("...alerts");
7✔
5406
        $d['alerts'] = $this->dbhr->preQuery("SELECT subject, responded, response FROM alerts_tracking INNER JOIN alerts ON alerts_tracking.alertid = alerts.id WHERE userid = ? AND responded IS NOT NULL ORDER BY responded ASC;", [
7✔
5407
            $this->id
7✔
5408
        ]);
7✔
5409

5410
        foreach ($d['alerts'] as &$s) {
7✔
5411
            $s['responded'] = Utils::ISODate($s['responded']);
×
5412
        }
5413

5414
        error_log("...donations");
7✔
5415
        $d['donations'] = $this->dbhr->preQuery("SELECT * FROM users_donations WHERE userid = ? ORDER BY timestamp ASC;", [
7✔
5416
            $this->id
7✔
5417
        ]);
7✔
5418

5419
        foreach ($d['donations'] as &$s) {
7✔
5420
            $s['timestamp'] = Utils::ISODate($s['timestamp']);
×
5421
        }
5422

5423
        error_log("...bans");
7✔
5424
        $d['bans'] = [];
7✔
5425

5426
        $bans = $this->dbhr->preQuery("SELECT * FROM users_banned WHERE byuser = ?;", [
7✔
5427
            $this->id
7✔
5428
        ]);
7✔
5429

5430
        foreach ($bans as $ban) {
7✔
5431
            $g = Group::get($this->dbhr, $this->dbhm, $ban['groupid']);
1✔
5432
            $u = User::get($this->dbhr, $this->dbhm, $ban['userid']);
1✔
5433
            $d['bans'][] = [
1✔
5434
                'date' => Utils::ISODate($ban['date']),
1✔
5435
                'group' => $g->getName(),
1✔
5436
                'email' => $u->getEmailPreferred(),
1✔
5437
                'userid' => $ban['userid']
1✔
5438
            ];
1✔
5439
        }
5440

5441
        error_log("...spammers");
7✔
5442
        $d['spammers'] = $this->dbhr->preQuery("SELECT * FROM spam_users WHERE byuserid = ? ORDER BY added ASC;", [
7✔
5443
            $this->id
7✔
5444
        ]);
7✔
5445

5446
        foreach ($d['spammers'] as &$s) {
7✔
5447
            $s['added'] = Utils::ISODate($s['added']);
×
5448
            $u = User::get($this->dbhr, $this->dbhm, $s['userid']);
×
5449
            $s['email'] = $u->getEmailPreferred();
×
5450
        }
5451

5452
        $d['spamdomains'] = $this->dbhr->preQuery("SELECT domain, date FROM spam_whitelist_links WHERE userid = ?;", [
7✔
5453
            $this->id
7✔
5454
        ]);
7✔
5455

5456
        foreach ($d['spamdomains'] as &$s) {
7✔
5457
            $s['date'] = Utils::ISODate($s['date']);
×
5458
        }
5459

5460
        error_log("...images");
7✔
5461
        $images = $this->dbhr->preQuery("SELECT id, url FROM users_images WHERE userid = ?;", [
7✔
5462
            $this->id
7✔
5463
        ]);
7✔
5464

5465
        $d['images'] = [];
7✔
5466

5467
        foreach ($images as $image) {
7✔
5468
            if (Utils::pres('url', $image)) {
6✔
5469
                $d['images'][] = [
6✔
5470
                    'id' => $image['id'],
6✔
5471
                    'thumb' => $image['url']
6✔
5472
                ];
6✔
5473
            } else {
5474
                $a = new Attachment($this->dbhr, $this->dbhm, NULL, Attachment::TYPE_USER);
×
5475
                $d['images'][] = [
×
5476
                    'id' => $image['id'],
×
5477
                    'thumb' => $a->getPath(TRUE, $image['id'])
×
5478
                ];
×
5479
            }
5480
        }
5481

5482
        error_log("...notifications");
7✔
5483
        $d['notifications'] = $this->dbhr->preQuery("SELECT timestamp, url FROM users_notifications WHERE touser = ? AND seen = 1;", [
7✔
5484
            $this->id
7✔
5485
        ]);
7✔
5486

5487
        foreach ($d['notifications'] as &$n) {
7✔
5488
            $n['timestamp'] = Utils::ISODate($n['timestamp']);
×
5489
        }
5490

5491
        error_log("...addresses");
7✔
5492
        $d['addresses'] = [];
7✔
5493

5494
        $addrs = $this->dbhr->preQuery("SELECT * FROM users_addresses WHERE userid = ?;", [
7✔
5495
            $this->id
7✔
5496
        ]);
7✔
5497

5498
        foreach ($addrs as $addr) {
7✔
5499
            $a = new Address($this->dbhr, $this->dbhm, $addr['id']);
×
5500
            $d['addresses'][] = $a->getPublic();
×
5501
        }
5502

5503
        error_log("...events");
7✔
5504
        $d['communityevents'] = [];
7✔
5505

5506
        $events = $this->dbhr->preQuery("SELECT id FROM communityevents WHERE userid = ?;", [
7✔
5507
            $this->id
7✔
5508
        ]);
7✔
5509

5510
        foreach ($events as $event) {
7✔
5511
            $e = new CommunityEvent($this->dbhr, $this->dbhm, $event['id']);
×
5512
            $d['communityevents'][] = $e->getPublic();
×
5513
        }
5514

5515
        error_log("...volunteering");
7✔
5516
        $d['volunteering'] = [];
7✔
5517

5518
        $events = $this->dbhr->preQuery("SELECT id FROM volunteering WHERE userid = ?;", [
7✔
5519
            $this->id
7✔
5520
        ]);
7✔
5521

5522
        foreach ($events as $event) {
7✔
5523
            $e = new Volunteering($this->dbhr, $this->dbhm, $event['id']);
×
5524
            $d['volunteering'][] = $e->getPublic();
×
5525
        }
5526

5527
        error_log("...comments");
7✔
5528
        $d['comments'] = [];
7✔
5529
        $comms = $this->dbhr->preQuery("SELECT * FROM users_comments WHERE byuserid = ? ORDER BY date ASC;", [
7✔
5530
            $this->id
7✔
5531
        ]);
7✔
5532

5533
        foreach ($comms as &$comm) {
7✔
5534
            $u = User::get($this->dbhr, $this->dbhm, $comm['userid']);
1✔
5535
            $comm['email'] = $u->getEmailPreferred();
1✔
5536
            $comm['date'] = Utils::ISODate($comm['date']);
1✔
5537
            $d['comments'][] = $comm;
1✔
5538
        }
5539

5540
        error_log("...ratings");
7✔
5541
        $d['ratings'] = $this->getRated();
7✔
5542

5543
        error_log("...locations");
7✔
5544
        $d['locations'] = [];
7✔
5545

5546
        $locs = $this->dbhr->preQuery("SELECT * FROM locations_excluded WHERE userid = ?;", [
7✔
5547
            $this->id
7✔
5548
        ]);
7✔
5549

5550
        foreach ($locs as $loc) {
7✔
5551
            $g = Group::get($this->dbhr, $this->dbhm, $loc['groupid']);
×
5552
            $l = new Location($this->dbhr, $this->dbhm, $loc['locationid']);
×
5553
            $d['locations'][] = [
×
5554
                'group' => $g->getName(),
×
5555
                'location' => $l->getPrivate('name'),
×
5556
                'date' => Utils::ISODate($loc['date'])
×
5557
            ];
×
5558
        }
5559

5560
        error_log("...messages");
7✔
5561
        $msgs = $this->dbhr->preQuery("SELECT id FROM messages WHERE fromuser = ? ORDER BY arrival ASC;", [
7✔
5562
            $this->id
7✔
5563
        ]);
7✔
5564

5565
        $d['messages'] = [];
7✔
5566

5567
        foreach ($msgs as $msg) {
7✔
5568
            $m = new Message($this->dbhr, $this->dbhm, $msg['id']);
×
5569

5570
            # Show all info here even moderator attributes.  This wouldn't normally be shown to users, but none
5571
            # of it is confidential really.
5572
            $thisone = $m->getPublic(FALSE, FALSE, TRUE);
×
5573

5574
            if (count($thisone['groups']) > 0) {
×
5575
                $g = Group::get($this->dbhr, $this->dbhm, $thisone['groups'][0]['groupid']);
×
5576
                $thisone['groups'][0]['namedisplay'] = $g->getName();
×
5577
            }
5578

5579
            $d['messages'][] = $thisone;
×
5580
        }
5581

5582
        # Chats.  Can't use listForUser as that filters on various things and has a ModTools vs FD distinction, and
5583
        # we're interested in information we have provided.  So we get the chats mentioned in the roster (we have
5584
        # provided information about being online) and where we have sent or reviewed a chat message.
5585
        error_log("...chats");
7✔
5586
        $chatids = $this->dbhr->preQuery("SELECT DISTINCT id, latestmessage FROM chat_rooms INNER JOIN (SELECT DISTINCT chatid FROM chat_roster WHERE userid = ? UNION SELECT DISTINCT chatid FROM chat_messages WHERE userid = ? OR reviewedby = ?) t ON t.chatid = chat_rooms.id ORDER BY latestmessage ASC;", [
7✔
5587
            $this->id,
7✔
5588
            $this->id,
7✔
5589
            $this->id
7✔
5590
        ]);
7✔
5591

5592
        $d['chatrooms'] = [];
7✔
5593
        $count = 0;
7✔
5594

5595
        foreach ($chatids as $chatid) {
7✔
5596
            # We don't return the chat name because it's too slow to produce.
5597
            $r = new ChatRoom($this->dbhr, $this->dbhm, $chatid['id']);
6✔
5598
            $thisone = [
6✔
5599
                'id' => $chatid['id'],
6✔
5600
                'name' => $r->getPublic($this)['name'],
6✔
5601
                'messages' => []
6✔
5602
            ];
6✔
5603

5604
            $sql = "SELECT date, lastip FROM chat_roster WHERE `chatid` = ? AND userid = ?;";
6✔
5605
            $roster = $this->dbhr->preQuery($sql, [$chatid['id'], $this->id]);
6✔
5606
            foreach ($roster as $rost) {
6✔
5607
                $thisone['lastip'] = $rost['lastip'];
6✔
5608
                $thisone['date'] = Utils::ISODate($rost['date']);
6✔
5609
            }
5610

5611
            # Get the messages we have sent in this chat.
5612
            $msgs = $this->dbhr->preQuery("SELECT id FROM chat_messages WHERE chatid = ? AND (userid = ? OR reviewedby = ?);", [
6✔
5613
                $chatid['id'],
6✔
5614
                $this->id,
6✔
5615
                $this->id
6✔
5616
            ]);
6✔
5617

5618
            $userlist = NULL;
6✔
5619

5620
            foreach ($msgs as $msg) {
6✔
5621
                $cm = new ChatMessage($this->dbhr, $this->dbhm, $msg['id']);
6✔
5622
                $thismsg = $cm->getPublic(FALSE, $userlist);
6✔
5623

5624
                # Strip out most of the refmsg detail - it's not ours and we need to save volume of data.
5625
                $refmsg = Utils::pres('refmsg', $thismsg);
6✔
5626

5627
                if ($refmsg) {
6✔
5628
                    $thismsg['refmsg'] = [
×
5629
                        'id' => $msg['id'],
×
5630
                        'subject' => Utils::presdef('subject', $refmsg, NULL)
×
5631
                    ];
×
5632
                }
5633

5634
                $thismsg['mine'] = Utils::presdef('userid', $thismsg, NULL) == $this->id;
6✔
5635
                $thismsg['date'] = Utils::ISODate($thismsg['date']);
6✔
5636
                $thisone['messages'][] = $thismsg;
6✔
5637

5638
                $count++;
6✔
5639
//
5640
//                if ($count > 200) {
5641
//                    break 2;
5642
//                }
5643
            }
5644

5645
            if (count($thisone['messages']) > 0) {
6✔
5646
                $d['chatrooms'][] = $thisone;
6✔
5647
            }
5648
        }
5649

5650
        error_log("...newsfeed");
7✔
5651
        $newsfeeds = $this->dbhr->preQuery("SELECT * FROM newsfeed WHERE userid = ?;", [
7✔
5652
            $this->id
7✔
5653
        ]);
7✔
5654

5655
        $d['newsfeed'] = [];
7✔
5656

5657
        foreach ($newsfeeds as $newsfeed) {
7✔
5658
            $n = new Newsfeed($this->dbhr, $this->dbhm, $newsfeed['id']);
6✔
5659
            $thisone = $n->getPublic(FALSE, FALSE, FALSE, FALSE);
6✔
5660
            $d['newsfeed'][] = $thisone;
6✔
5661
        }
5662

5663
        $d['newsfeed_unfollows'] = $this->dbhr->preQuery("SELECT * FROM newsfeed_unfollow WHERE userid = ?;", [
7✔
5664
            $this->id
7✔
5665
        ]);
7✔
5666

5667
        foreach ($d['newsfeed_unfollows'] as &$dd) {
7✔
5668
            $dd['timestamp'] = Utils::ISODate($dd['timestamp']);
×
5669
        }
5670

5671
        $d['newsfeed_likes'] = $this->dbhr->preQuery("SELECT * FROM newsfeed_likes WHERE userid = ?;", [
7✔
5672
            $this->id
7✔
5673
        ]);
7✔
5674

5675
        foreach ($d['newsfeed_likes'] as &$dd) {
7✔
5676
            $dd['timestamp'] = Utils::ISODate($dd['timestamp']);
×
5677
        }
5678

5679
        $d['newsfeed_reports'] = $this->dbhr->preQuery("SELECT * FROM newsfeed_reports WHERE userid = ?;", [
7✔
5680
            $this->id
7✔
5681
        ]);
7✔
5682

5683
        foreach ($d['newsfeed_reports'] as &$dd) {
7✔
5684
            $dd['timestamp'] = Utils::ISODate($dd['timestamp']);
×
5685
        }
5686

5687
        $d['aboutme'] = $this->dbhr->preQuery("SELECT timestamp, text FROM users_aboutme WHERE userid = ? AND LENGTH(text) > 5;", [
7✔
5688
            $this->id
7✔
5689
        ]);
7✔
5690

5691
        foreach ($d['aboutme'] as &$dd) {
7✔
5692
            $dd['timestamp'] = Utils::ISODate($dd['timestamp']);
×
5693
        }
5694

5695
        error_log("...stories");
7✔
5696
        $d['stories'] = $this->dbhr->preQuery("SELECT date, headline, story FROM users_stories WHERE userid = ?;", [
7✔
5697
            $this->id
7✔
5698
        ]);
7✔
5699

5700
        foreach ($d['stories'] as &$dd) {
7✔
5701
            $dd['date'] = Utils::ISODate($dd['date']);
×
5702
        }
5703

5704
        $d['stories_likes'] = $this->dbhr->preQuery("SELECT storyid FROM users_stories_likes WHERE userid = ?;", [
7✔
5705
            $this->id
7✔
5706
        ]);
7✔
5707

5708
        error_log("...exports");
7✔
5709
        $d['exports'] = $this->dbhr->preQuery("SELECT userid, started, completed FROM users_exports WHERE userid = ?;", [
7✔
5710
            $this->id
7✔
5711
        ]);
7✔
5712

5713
        foreach ($d['exports'] as &$dd) {
7✔
5714
            $dd['started'] = Utils::ISODate($dd['started']);
7✔
5715
            $dd['completed'] = Utils::ISODate($dd['completed']);
7✔
5716
        }
5717

5718
        error_log("...logs");
7✔
5719
        $l = new Log($this->dbhr, $this->dbhm);
7✔
5720
        $ctx = NULL;
7✔
5721
        $d['logs'] = $l->get(NULL, NULL, NULL, NULL, NULL, NULL, PHP_INT_MAX, $ctx, $this->id);
7✔
5722

5723
        error_log("...add group to logs");
7✔
5724
        $loggroups = [];
7✔
5725
        foreach ($d['logs'] as &$log) {
7✔
5726
            if (Utils::pres('groupid', $log)) {
7✔
5727
                # Don't put the whole group info in there, as it is slow to get.
5728
                if (!array_key_exists($log['groupid'], $loggroups)) {
7✔
5729
                    $g = Group::get($this->dbhr, $this->dbhm, $log['groupid']);
7✔
5730

5731
                    if ($g->getId() == $log['groupid']) {
7✔
5732
                        $loggroups[$log['groupid']] = [
7✔
5733
                            'id' => $log['groupid'],
7✔
5734
                            'nameshort' => $g->getPrivate('nameshort'),
7✔
5735
                            'namedisplay' => $g->getName()
7✔
5736
                        ];
7✔
5737
                    } else {
5738
                        $loggroups[$log['groupid']] = [
×
5739
                            'id' => $log['groupid'],
×
5740
                            'nameshort' => "DeletedGroup{$log['groupid']}",
×
5741
                            'namedisplay' => "Deleted group #{$log['groupid']}"
×
5742
                        ];
×
5743
                    }
5744
                }
5745

5746
                $log['group'] = $loggroups[$log['groupid']];
7✔
5747
            }
5748
        }
5749

5750
        # Gift aid
5751
        $don = new Donations($this->dbhr, $this->dbhm);
7✔
5752
        $d['giftaid'] = $don->getGiftAid($this->id);
7✔
5753

5754
        $ret = $d;
7✔
5755

5756
        # There are some other tables with information which we don't return.  Here's what and why:
5757
        # - Not part of the current UI so can't have any user data
5758
        #     polls_users
5759
        # - Covered by data that we do return from other tables
5760
        #     messages_drafts, messages_history, messages_groups, messages_outcomes,
5761
        #     messages_promises, users_modmails, modnotifs, users_dashboard,
5762
        #     users_nudges
5763
        # - Transient logging data
5764
        #     logs_emails, logs_sql, logs_errors, logs_src
5765
        # - Not provided by the user themselves
5766
        #     user_comments, messages_reneged, spam_users, users_banned, users_stories_requested,
5767
        #     users_thanks
5768
        # - Inferred or derived data.  These are not considered to be provided by the user (see p10 of
5769
        #   http://ec.europa.eu/newsroom/document.cfm?doc_id=44099)
5770
        #     users_kudos, visualise
5771

5772
        # Compress the data in the DB because it can be huge.
5773
        #
5774
        error_log("...filter");
7✔
5775
        Utils::filterResult($ret);
7✔
5776
        error_log("...encode");
7✔
5777
        $data = json_encode($ret, JSON_PARTIAL_OUTPUT_ON_ERROR);
7✔
5778
        error_log("...encoded length " . strlen($data) . ", now compress");
7✔
5779
        $data = gzdeflate($data);
7✔
5780
        $this->dbhm->preExec("UPDATE users_exports SET completed = NOW(), data = ? WHERE id = ? AND tag = ?;", [
7✔
5781
            $data,
7✔
5782
            $exportid,
7✔
5783
            $tag
7✔
5784
        ]);
7✔
5785
        error_log("...completed, length " . strlen($data));
7✔
5786

5787
        return ($ret);
7✔
5788
    }
5789

5790
    function getExport($userid, $id, $tag)
5791
    {
5792
        $ret = NULL;
2✔
5793

5794
        $exports = $this->dbhr->preQuery("SELECT * FROM users_exports WHERE userid = ? AND id = ? AND tag = ?;", [
2✔
5795
            $userid,
2✔
5796
            $id,
2✔
5797
            $tag
2✔
5798
        ]);
2✔
5799

5800
        foreach ($exports as $export) {
2✔
5801
            $ret = $export;
2✔
5802
            $ret['requested'] = $ret['requested'] ? Utils::ISODate($ret['requested']) : NULL;
2✔
5803
            $ret['started'] = $ret['started'] ? Utils::ISODate($ret['started']) : NULL;
2✔
5804
            $ret['completed'] = $ret['completed'] ? Utils::ISODate($ret['completed']) : NULL;
2✔
5805

5806
            if ($ret['completed']) {
2✔
5807
                # This has completed.  Return the data.  Will be zapped in cron exports..
5808
                $ret['data'] = json_decode(gzinflate($export['data']), TRUE);
2✔
5809
                $ret['infront'] = 0;
2✔
5810
            } else {
5811
                # Find how many are in front of us.
5812
                $infront = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM users_exports WHERE id < ? AND completed IS NULL;", [
2✔
5813
                    $id
2✔
5814
                ]);
2✔
5815

5816
                $ret['infront'] = $infront[0]['count'];
2✔
5817
            }
5818
        }
5819

5820
        return ($ret);
2✔
5821
    }
5822

5823
    public function limbo() {
5824
        # We set the deleted attribute, which will cause the v2 Go API not to return any personal data.  The user
5825
        # can still log in, and potentially recover their account by calling session with PATCH of deleted = NULL.
5826
        # Otherwise a background script will purge their account after a couple of weeks.
5827
        #
5828
        # This allows us to handle the fairly common case of users deleting their accounts by mistake, or changing
5829
        # their minds.  This often happens because one-click unsubscribe in emails, which we need to have for
5830
        # delivery.
5831
        $this->dbhm->preExec("UPDATE users SET deleted = NOW() WHERE id = ?;", [
5✔
5832
            $this->id
5✔
5833
        ]);
5✔
5834

5835
        # Send email notification about account removal
5836
        $email = $this->getEmailPreferred();
5✔
5837
        if ($email) {
5✔
5838
            try {
5839
                $loader = new \Twig_Loader_Filesystem(IZNIK_BASE . '/mailtemplates/twig');
5✔
5840
                $twig = new \Twig_Environment($loader);
5✔
5841

5842
                list ($transport, $mailer) = Mail::getMailer();
5✔
5843

5844
                $html = $twig->render('limbo.html', [
5✔
5845
                    'site_url' => 'https://' . USER_SITE
5✔
5846
                ]);
5✔
5847

5848
                $message = \Swift_Message::newInstance()
5✔
5849
                    ->setSubject("Your Freegle account has been removed as requested")
5✔
5850
                    ->setFrom([NOREPLY_ADDR => SITE_NAME])
5✔
5851
                    ->setReplyTo(SUPPORT_ADDR)
5✔
5852
                    ->setTo($email)
5✔
5853
                    ->setBody("We've removed your Freegle account as requested. Your personal data has been marked for deletion and will be completely removed from our systems within 14 days.\n\nIf you changed your mind or removed your account by mistake, don't worry! You can still reactivate it by visiting " . USER_SITE . " and logging back in.\n\nOnce you visit the site and log back in, your account will be automatically restored with all your data intact.\n\nIf you meant to remove your account, you can safely ignore this email. After 14 days, your data will be permanently deleted and cannot be recovered.");
5✔
5854

5855
                # Add HTML in base-64 as default quoted-printable encoding leads to problems on
5856
                # Outlook.
5857
                $htmlPart = \Swift_MimePart::newInstance();
5✔
5858
                $htmlPart->setCharset('utf-8');
5✔
5859
                $htmlPart->setEncoder(new \Swift_Mime_ContentEncoder_Base64ContentEncoder);
5✔
5860
                $htmlPart->setContentType('text/html');
5✔
5861
                $htmlPart->setBody($html);
5✔
5862
                $message->attach($htmlPart);
5✔
5863

5864
                Mail::addHeaders($this->dbhr, $this->dbhm, $message, Mail::LIMBO, $this->id);
5✔
5865
                $this->sendIt($mailer, $message);
5✔
5866
            } catch (\Exception $e) {
×
5867
                error_log("Failed to send limbo email to user {$this->id}: " . $e->getMessage());
×
5868
            }
5869
        }
5870
    }
5871

5872
    public function processForgets($id = NULL) {
5873
        $count = 0;
1✔
5874

5875
        $idq = $id ? "AND id = $id" : "";
1✔
5876
        $users = $this->dbhr->preQuery("SELECT id, deleted FROM users WHERE deleted IS NOT NULL AND DATEDIFF(NOW(), deleted) > 14 AND forgotten IS NULL $idq;");
1✔
5877

5878
        foreach ($users as $user) {
1✔
5879
            $u = new User($this->dbhr, $this->dbhm, $user['id']);
1✔
5880
            $u->forget('Grace period');
1✔
5881
            $count++;
1✔
5882
        }
5883

5884
        return $count;
1✔
5885
    }
5886

5887
    public function forget($reason)
5888
    {
5889
        # Wipe a user of personal data, for the GDPR right to be forgotten.  We don't delete the user entirely
5890
        # otherwise it would mess up the stats.
5891

5892
        # Clear name etc.
5893
        $this->setPrivate('firstname', NULL);
11✔
5894
        $this->setPrivate('lastname', NULL);
11✔
5895
        $this->setPrivate('fullname', "Deleted User #" . $this->id);
11✔
5896
        $this->setPrivate('settings', NULL);
11✔
5897
        $this->setPrivate('yahooid', NULL);
11✔
5898

5899
        # Delete emails which aren't ours.
5900
        $emails = $this->getEmails();
11✔
5901

5902
        foreach ($emails as $email) {
11✔
5903
            if (!$email['ourdomain']) {
8✔
5904
                $this->removeEmail($email['email']);
8✔
5905
            }
5906
        }
5907

5908
        # Delete all logins.
5909
        $this->dbhm->preExec("DELETE FROM users_logins WHERE userid = ?;", [
11✔
5910
            $this->id
11✔
5911
        ]);
11✔
5912

5913
        # Delete the content (but not subject) of any messages, and any email header information such as their
5914
        # name and email address.
5915
        $msgs = $this->dbhm->preQuery("SELECT id FROM messages WHERE fromuser = ? AND messages.type IN (?, ?);", [
11✔
5916
            $this->id,
11✔
5917
            Message::TYPE_OFFER,
11✔
5918
            Message::TYPE_WANTED
11✔
5919
        ]);
11✔
5920

5921
        foreach ($msgs as $msg) {
11✔
5922
            $this->dbhm->preExec("UPDATE messages SET fromip = NULL, message = NULL, envelopefrom = NULL, fromname = NULL, fromaddr = NULL, messageid = NULL, textbody = NULL, htmlbody = NULL, deleted = NOW() WHERE id = ?;", [
1✔
5923
                $msg['id']
1✔
5924
            ]);
1✔
5925

5926
            $this->dbhm->preExec("UPDATE messages_groups SET deleted = 1 WHERE msgid = ?;", [
1✔
5927
                $msg['id']
1✔
5928
            ]);
1✔
5929

5930
            # Delete outcome comments that they've added - just about might have personal data.
5931
            $this->dbhm->preExec("UPDATE messages_outcomes SET comments = NULL WHERE msgid = ?;", [
1✔
5932
                $msg['id']
1✔
5933
            ]);
1✔
5934

5935
            $m = new Message($this->dbhr, $this->dbhm, $msg['id']);
1✔
5936

5937
            if (!$m->hasOutcome()) {
1✔
5938
                $m->withdraw('Withdrawn on user unsubscribe', NULL);
1✔
5939
            }
5940
        }
5941

5942
        # Remove all the content of all chat messages which they have sent (but not received).
5943
        $msgs = $this->dbhm->preQuery("SELECT id FROM chat_messages WHERE userid = ?;", [
11✔
5944
            $this->id
11✔
5945
        ]);
11✔
5946

5947
        foreach ($msgs as $msg) {
11✔
5948
            $this->dbhm->preExec("UPDATE chat_messages SET message = NULL WHERE id = ?;", [
1✔
5949
                $msg['id']
1✔
5950
            ]);
1✔
5951
        }
5952

5953
        # Delete completely any community events, volunteering opportunities, newsfeed posts, searches and stories
5954
        # they have created (their personal details might be in there), and any ratings by or about them.
5955
        $this->dbhm->preExec("DELETE FROM communityevents WHERE userid = ?;", [
11✔
5956
            $this->id
11✔
5957
        ]);
11✔
5958
        $this->dbhm->preExec("DELETE FROM volunteering WHERE userid = ?;", [
11✔
5959
            $this->id
11✔
5960
        ]);
11✔
5961
        $this->dbhm->preExec("DELETE FROM newsfeed WHERE userid = ?;", [
11✔
5962
            $this->id
11✔
5963
        ]);
11✔
5964
        $this->dbhm->preExec("DELETE FROM users_stories WHERE userid = ?;", [
11✔
5965
            $this->id
11✔
5966
        ]);
11✔
5967
        $this->dbhm->preExec("DELETE FROM users_searches WHERE userid = ?;", [
11✔
5968
            $this->id
11✔
5969
        ]);
11✔
5970
        $this->dbhm->preExec("DELETE FROM users_aboutme WHERE userid = ?;", [
11✔
5971
            $this->id
11✔
5972
        ]);
11✔
5973
        $this->dbhm->preExec("DELETE FROM ratings WHERE rater = ?;", [
11✔
5974
            $this->id
11✔
5975
        ]);
11✔
5976
        $this->dbhm->preExec("DELETE FROM ratings WHERE ratee = ?;", [
11✔
5977
            $this->id
11✔
5978
        ]);
11✔
5979

5980
        # Remove them from all groups.
5981
        $membs = $this->getMemberships();
11✔
5982

5983
        foreach ($membs as $memb) {
11✔
5984
            $this->removeMembership($memb['id']);
7✔
5985
        }
5986

5987
        # Delete any postal addresses
5988
        $this->dbhm->preExec("DELETE FROM users_addresses WHERE userid = ?;", [
11✔
5989
            $this->id
11✔
5990
        ]);
11✔
5991

5992
        # Delete any profile images
5993
        $this->dbhm->preExec("DELETE FROM users_images WHERE userid = ?;", [
11✔
5994
            $this->id
11✔
5995
        ]);
11✔
5996

5997
        # Remove any promises.
5998
        $this->dbhm->preExec("DELETE FROM messages_promises WHERE userid = ?;", [
11✔
5999
            $this->id
11✔
6000
        ]);
11✔
6001

6002
        $this->dbhm->preExec("UPDATE users SET forgotten = NOW(), tnuserid = NULL WHERE id = ?;", [
11✔
6003
            $this->id
11✔
6004
        ]);
11✔
6005

6006
        $this->dbhm->preExec("DELETE FROM sessions WHERE userid = ?;", [
11✔
6007
            $this->id
11✔
6008
        ]);
11✔
6009

6010
        $l = new Log($this->dbhr, $this->dbhm);
11✔
6011
        $l->log([
11✔
6012
            'type' => Log::TYPE_USER,
11✔
6013
            'subtype' => Log::SUBTYPE_DELETED,
11✔
6014
            'user' => $this->id,
11✔
6015
            'text' => $reason
11✔
6016
        ]);
11✔
6017
    }
6018

6019
    public function userRetention($userid = NULL)
6020
    {
6021
        # Find users who:
6022
        # - were added six months ago
6023
        # - are not on any groups
6024
        # - have not logged in for six months
6025
        # - are not on the spammer list
6026
        # - do not have mod notes
6027
        # - have no logs for six months
6028
        #
6029
        # We have no good reason to keep any data about them, and should therefore purge them.
6030
        $count = 0;
1✔
6031
        $userq = $userid ? " users.id = $userid AND " : '';
1✔
6032
        $mysqltime = date("Y-m-d", strtotime("6 months ago"));
1✔
6033
        
6034
        # First, delete users with @yahoogroups.com emails (test/old emails)
6035
        $yahoosql = "SELECT DISTINCT users.id FROM users 
1✔
6036
                     INNER JOIN users_emails ON users.id = users_emails.userid 
6037
                     WHERE $userq users_emails.email LIKE '%@yahoogroups.com' AND users.deleted IS NULL;";
1✔
6038
        $yahooUsers = $this->dbhr->preQuery($yahoosql);
1✔
6039
        
6040
        foreach ($yahooUsers as $user) {
1✔
6041
            error_log("Deleting Yahoo Groups user #{$user['id']}");
×
6042
            $u = new User($this->dbhr, $this->dbhm, $user['id']);
×
6043
            $u->delete();
×
6044
            $count++;
×
6045
            
6046
            # Prod garbage collection
6047
            User::clearCache();
×
6048
            gc_collect_cycles();
×
6049
        }
6050
        
6051
        $sql = "SELECT users.id FROM users LEFT JOIN memberships ON users.id = memberships.userid LEFT JOIN spam_users ON users.id = spam_users.userid LEFT JOIN users_comments ON users.id = users_comments.userid WHERE $userq memberships.userid IS NULL AND spam_users.userid IS NULL AND users.lastaccess < '$mysqltime' AND systemrole = ? AND users.deleted IS NULL;";
1✔
6052
        $users = $this->dbhr->preQuery($sql, [
1✔
6053
            User::SYSTEMROLE_USER
1✔
6054
        ]);
1✔
6055

6056
        foreach ($users as $user) {
1✔
6057
            $logs = $this->dbhr->preQuery("SELECT DATEDIFF(NOW(), timestamp) AS logsago FROM logs WHERE user = ? AND (type != ? OR (subtype != ? AND subtype != ?)) ORDER BY id DESC LIMIT 1;", [
1✔
6058
                $user['id'],
1✔
6059
                Log::TYPE_USER,
1✔
6060
                Log::SUBTYPE_CREATED,
1✔
6061
                Log::SUBTYPE_DELETED
1✔
6062
            ]);
1✔
6063

6064
            error_log("#{$user['id']} Found logs " . count($logs) . " age " . (count($logs) > 0 ? $logs['0']['logsago'] : ' none '));
1✔
6065

6066
            if (count($logs) == 0 || $logs[0]['logsago'] > 90) {
1✔
6067
                error_log("...forget user #{$user['id']} " . (count($logs) > 0 ? $logs[0]['logsago'] : ''));
1✔
6068
                $u = new User($this->dbhr, $this->dbhm, $user['id']);
1✔
6069
                $u->forget('Inactive');
1✔
6070
                $count++;
1✔
6071
            }
6072

6073
            # Prod garbage collection, as we've seen high memory usage by this.
6074
            User::clearCache();
1✔
6075
            gc_collect_cycles();
1✔
6076
        }
6077

6078
        # The only reason for preserving deleted users is as a placeholder user for messages they sent.  If they
6079
        # don't have any messages, they can go.
6080
        $ids = $this->dbhr->preQuery("SELECT users.id FROM `users` LEFT JOIN messages ON messages.fromuser = users.id WHERE users.forgotten IS NOT NULL AND users.lastaccess < ? AND messages.id IS NULL LIMIT 100000;", [
1✔
6081
            $mysqltime
1✔
6082
        ]);
1✔
6083

6084
        $total = count($ids);
1✔
6085
        $count = 0;
1✔
6086

6087
        foreach ($ids as $id) {
1✔
6088
            $u = new User($this->dbhr, $this->dbhm, $id['id']);
1✔
6089
            #error_log("...delete user #{$id['id']}");
6090
            $u->delete();
1✔
6091

6092
            $count++;
1✔
6093

6094
            if ($count % 1000 == 0) {
1✔
6095
                error_log("...delete $count / $total");
×
6096
            }
6097

6098

6099
            # Prod garbage collection, as we've seen high memory usage by this.
6100
            User::clearCache();
1✔
6101
            gc_collect_cycles();
1✔
6102
        }
6103

6104
        return ($count);
1✔
6105
    }
6106

6107
    public function recordActive()
6108
    {
6109
        # We record this on an hourly basis.  Avoid pointless mod ops for cluster health.
6110
        $now = date("Y-m-d H:00:00", time());
2✔
6111
        $already = $this->dbhr->preQuery("SELECT * FROM users_active WHERE userid = ? AND timestamp = ?;", [
2✔
6112
            $this->id,
2✔
6113
            $now
2✔
6114
        ]);
2✔
6115

6116
        if (count($already) == 0) {
2✔
6117
            $this->dbhm->background("INSERT IGNORE INTO users_active (userid, timestamp) VALUES ({$this->id}, '$now');");
2✔
6118
        }
6119
    }
6120

6121
    public function getActive()
6122
    {
6123
        $active = $this->dbhr->preQuery("SELECT * FROM users_active WHERE userid = ?;", [$this->id]);
1✔
6124
        return ($active);
1✔
6125
    }
6126

6127
    public function mostActive($gid, $limit = 20)
6128
    {
6129
        $limit = intval($limit);
1✔
6130
        $earliest = date("Y-m-d", strtotime("Midnight 30 days ago"));
1✔
6131

6132
        $users = $this->dbhr->preQuery("SELECT users_active.userid, COUNT(*) AS count FROM users_active inner join users ON users.id = users_active.userid INNER JOIN memberships ON memberships.userid = users.id WHERE groupid = ? AND systemrole = ? AND timestamp >= ? GROUP BY users_active.userid ORDER BY count DESC LIMIT $limit", [
1✔
6133
            $gid,
1✔
6134
            User::SYSTEMROLE_USER,
1✔
6135
            $earliest
1✔
6136
        ]);
1✔
6137

6138
        $ret = [];
1✔
6139

6140
        foreach ($users as $user) {
1✔
6141
            $u = User::get($this->dbhr, $this->dbhm, $user['userid']);
1✔
6142
            $thisone = $u->getPublic();
1✔
6143
            $thisone['groupid'] = $gid;
1✔
6144
            $thisone['email'] = $u->getEmailPreferred();
1✔
6145

6146
            if (Utils::pres('memberof', $thisone)) {
1✔
6147
                foreach ($thisone['memberof'] as $group) {
1✔
6148
                    if ($group['id'] == $gid) {
1✔
6149
                        $thisone['joined'] = $group['added'];
1✔
6150
                    }
6151
                }
6152
            }
6153

6154
            $ret[] = $thisone;
1✔
6155
        }
6156

6157
        return ($ret);
1✔
6158
    }
6159

6160
    public function setAboutMe($text) {
6161
        $this->dbhm->preExec("INSERT INTO users_aboutme (userid, text) VALUES (?, ?);", [
3✔
6162
            $this->id,
3✔
6163
            $text
3✔
6164
        ]);
3✔
6165

6166
        $this->dbhm->background("UPDATE users SET lastupdated = NOW() WHERE id = {$this->id};");
3✔
6167

6168
        return($this->dbhm->lastInsertId());
3✔
6169
    }
6170

6171
    public function rate($rater, $ratee, $rating, $reason = NULL, $text = NULL) {
6172
        $ret = NULL;
2✔
6173

6174
        if ($rater != $ratee) {
2✔
6175
            # Can't rate yourself.
6176
            $review = $rating == User::RATING_DOWN && $reason && $text;
2✔
6177
            $this->dbhm->preExec("REPLACE INTO ratings (rater, ratee, rating, reason, text, timestamp, reviewrequired) VALUES (?, ?, ?, ?, ?, NOW(), ?);", [
2✔
6178
                $rater,
2✔
6179
                $ratee,
2✔
6180
                $rating,
2✔
6181
                $reason,
2✔
6182
                $text,
2✔
6183
                $review
2✔
6184
            ]);
2✔
6185

6186
            $ret = $this->dbhm->lastInsertId();
2✔
6187

6188
            $this->dbhm->background("UPDATE users SET lastupdated = NOW() WHERE id IN ($rater, $ratee);");
2✔
6189
        }
6190

6191
        return($ret);
2✔
6192
    }
6193

6194
    public function getRatings($uids) {
6195
        $mysqltime = date("Y-m-d", strtotime("Midnight 182 days ago"));
130✔
6196
        $ret = [];
130✔
6197
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
130✔
6198
        $myid = $me ? $me->getId() : NULL;
130✔
6199

6200
        # We show visible ratings, ones we have made ourselves, or those from TN.
6201
        $sql = "SELECT ratee, COUNT(*) AS count, rating FROM ratings WHERE ratee IN (" . implode(',', $uids) . ") AND (timestamp >= '$mysqltime' OR rater = ?) AND (tn_rating_id IS NOT NULL OR rater = ? OR visible = 1) GROUP BY rating, ratee;";
130✔
6202
        $ratings = $this->dbhr->preQuery($sql, [ $myid, $myid ]);
130✔
6203

6204
        foreach ($uids as $uid) {
130✔
6205
            $ret[$uid] = [
130✔
6206
                User::RATING_UP => 0,
130✔
6207
                User::RATING_DOWN => 0,
130✔
6208
                User::RATING_MINE => NULL
130✔
6209
            ];
130✔
6210

6211
            foreach ($ratings as $rate) {
130✔
6212
                if ($rate['ratee'] == $uid) {
1✔
6213
                    $ret[$uid][$rate['rating']] = $rate['count'];
1✔
6214
                }
6215
            }
6216
        }
6217

6218
        $ratings = $this->dbhr->preQuery("SELECT rating, ratee FROM ratings WHERE ratee IN (" . implode(',', $uids) . ") AND rater = ? AND timestamp >= '$mysqltime';", [
130✔
6219
            $myid
130✔
6220
        ]);
130✔
6221

6222
        foreach ($uids as $uid) {
130✔
6223
            if ($myid != $this->id) {
130✔
6224
                # We can't rate ourselves, so don't bother checking.
6225

6226
                foreach ($ratings as $rating) {
80✔
6227
                    if ($rating['ratee'] == $uid) {
1✔
6228
                        $ret[$uid][User::RATING_MINE] = $rating['rating'];
1✔
6229
                    }
6230
                }
6231
            }
6232
        }
6233

6234
        return($ret);
130✔
6235
    }
6236

6237
    public function getAllRatings($since) {
6238
        $mysqltime = date("Y-m-d H:i:s", strtotime($since));
1✔
6239

6240
        $sql = "SELECT * FROM ratings WHERE timestamp >= ? AND visible = 1;";
1✔
6241
        $ratings = $this->dbhr->preQuery($sql, [
1✔
6242
            $mysqltime
1✔
6243
        ]);
1✔
6244

6245
        foreach ($ratings as &$rating) {
1✔
6246
            $rating['timestamp'] = Utils::ISODate($rating['timestamp']);
1✔
6247
        }
6248

6249
        return $ratings;
1✔
6250
    }
6251

6252
    public function getVisibleRatings($unreviewedonly, $since = '7 days ago') {
6253
        $mysqltime = date("Y-m-d H:i:s", strtotime($since));
3✔
6254
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
3✔
6255

6256
        $modships = $me->getModeratorships(NULL, TRUE);
3✔
6257

6258
        $ret = [];
3✔
6259
        $revq = $unreviewedonly ? " AND reviewrequired = 1" : '';
3✔
6260

6261
        if (count($modships)) {
3✔
6262
            $sql = "SELECT ratings.*, m1.groupid,
3✔
6263
       CASE WHEN u1.fullname IS NOT NULL THEN u1.fullname ELSE CONCAT(u1.firstname, ' ', u1.lastname) END AS raterdisplayname,
6264
       CASE WHEN u2.fullname IS NOT NULL THEN u2.fullname ELSE CONCAT(u2.firstname, ' ', u2.lastname) END AS rateedisplayname
6265
    FROM ratings 
6266
    INNER JOIN memberships m1 ON m1.userid = ratings.rater
6267
    INNER JOIN memberships m2 ON m2.userid = ratings.ratee
6268
    INNER JOIN users u1 ON ratings.rater = u1.id
6269
    INNER JOIN users u2 ON ratings.ratee = u2.id
6270
    WHERE ratings.timestamp >= ? AND 
6271
        m1.groupid IN (" . implode(',', $modships) . ") AND
3✔
6272
        m2.groupid IN (" . implode(',', $modships) . ") AND
3✔
6273
        m1.groupid = m2.groupid AND
6274
        ratings.rating IS NOT NULL 
6275
        $revq    
3✔
6276
        GROUP BY ratings.rater ORDER BY ratings.timestamp DESC;";
3✔
6277

6278
            $ret = $this->dbhr->preQuery($sql, [
3✔
6279
                $mysqltime
3✔
6280
            ]);
3✔
6281

6282
            foreach ($ret as &$r) {
3✔
6283
                $r['timestamp'] = Utils::ISODate($r['timestamp']);
1✔
6284
            }
6285
        }
6286

6287
        return $ret;
3✔
6288
    }
6289

6290
    public function ratingReviewed($ratingid) {
6291
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
1✔
6292

6293
        $unreviewed = $me->getVisibleRatings(TRUE);
1✔
6294

6295
        foreach ($unreviewed as $r) {
1✔
6296
            if ($r['id'] == $ratingid) {
1✔
6297
                $this->dbhm->preExec("UPDATE ratings SET reviewrequired = 0 WHERE id = ?;", [
1✔
6298
                    $ratingid
1✔
6299
                ]);
1✔
6300
            }
6301
        }
6302
    }
6303

6304
    public function getChanges($since) {
6305
        $mysqltime = date("Y-m-d H:i:s", strtotime($since));
1✔
6306

6307
        $users = $this->dbhr->preQuery("SELECT id, lastupdated FROM users WHERE lastupdated >= ?;", [
1✔
6308
            $mysqltime
1✔
6309
        ]);
1✔
6310

6311
        foreach ($users as &$user) {
1✔
6312
            $user['lastupdated'] = Utils::ISODate($user['lastupdated']);
1✔
6313
        }
6314

6315
        return $users;
1✔
6316
    }
6317

6318
    public function getRated() {
6319
        $rateds = $this->dbhr->preQuery("SELECT * FROM ratings WHERE rater = ?;", [
8✔
6320
            $this->id
8✔
6321
        ]);
8✔
6322

6323
        foreach ($rateds as &$rate) {
8✔
6324
            $rate['timestamp'] = Utils::ISODate($rate['timestamp']);
1✔
6325
        }
6326

6327
        return($rateds);
8✔
6328
    }
6329

6330
    public function getActiveSince($since, $createdbefore, $uid = NULL) {
6331
        $sincetime = date("Y-m-d H:i:s", strtotime($since));
1✔
6332
        $beforetime = date("Y-m-d H:i:s", strtotime($createdbefore));
1✔
6333
        $ids = $uid ? [
1✔
6334
            [
1✔
6335
                'id' => $uid
1✔
6336
            ]
1✔
6337
        ] : $this->dbhr->preQuery("SELECT id FROM users WHERE lastaccess >= ? AND added <= ?;", [
1✔
6338
            $sincetime,
1✔
6339
            $beforetime
1✔
6340
        ]);
1✔
6341

6342
        return(count($ids) ? array_filter(array_column($ids, 'id')) : []);
1✔
6343
    }
6344

6345
    public static function encodeId($id) {
6346
        # We're told that this is affecting our spam rating.  Let's see.
6347
        return '';
9✔
6348
//        $bin = base_convert($id, 10, 2);
6349
//        $bin = str_replace('0', '-', $bin);
6350
//        $bin = str_replace('1', '~', $bin);
6351
//        return($bin);
6352
    }
6353

6354
    public static function decodeId($enc) {
6355
        $enc = trim($enc);
5✔
6356
        $enc = str_replace('-', '0', $enc);
5✔
6357
        $enc = str_replace('~', '1', $enc);
5✔
6358
        $id  = base_convert($enc, 2, 10);
5✔
6359
        return($id);
5✔
6360
    }
6361

6362
    public function getCity()
6363
    {
6364
        $city = NULL;
21✔
6365

6366
        # Find the closest town
6367
        list ($lat, $lng, $loc) = $this->getLatLng(FALSE, TRUE);
21✔
6368

6369
        if ($lat || $lng) {
21✔
6370
            $sql = "SELECT id, name, ST_distance(position, ST_GeomFromText('POINT($lng $lat)', {$this->dbhr->SRID()})) AS dist FROM towns WHERE position IS NOT NULL ORDER BY dist ASC LIMIT 1;";
1✔
6371
            #error_log("Get $sql, $lng, $lat");
6372
            $towns = $this->dbhr->preQuery($sql);
1✔
6373

6374
            foreach ($towns as $town) {
1✔
6375
                $city = $town['name'];
1✔
6376
            }
6377
        }
6378

6379
        return([ $city, $lat, $lng ]);
21✔
6380
    }
6381

6382
    public function microVolunteering() {
6383
        // Are we on a group where microvolunteering is enabled.
6384
        $groups = $this->dbhr->preQuery("SELECT memberships.id FROM memberships INNER JOIN `groups` ON groups.id = memberships.groupid WHERE userid = ? AND microvolunteering = 1 LIMIT 1;", [
20✔
6385
            $this->id
20✔
6386
        ]);
20✔
6387

6388
        return count($groups);
20✔
6389
    }
6390

6391
    public function getJobAds() {
6392
        # We want to show a few job ads from nearby.
6393
        $search = NULL;
10✔
6394
        $ret = '<table style="width:100%; border-collapse:collapse;">';
10✔
6395

6396
        list ($lat, $lng) = $this->getLatLng();
10✔
6397

6398
        if ($lat || $lng) {
10✔
6399
            $j = new Jobs($this->dbhr, $this->dbhm);
3✔
6400
            $jobs = $j->query($lat, $lng, 4);
3✔
6401

6402
            foreach ($jobs as $job) {
3✔
6403
                $loc = Utils::presdef('location', $job, '');
1✔
6404

6405
                # Link via our site to avoid spam trap warnings.
6406
                $url = "https://" . USER_SITE . "/job/{$job['id']}";
1✔
6407
                $image = Utils::presdef('image', $job, '');
1✔
6408

6409
                $ret .= '<tr style="vertical-align:top;">';
1✔
6410

6411
                # Image column - small fixed width.
6412
                if ($image) {
1✔
6413
                    $ret .= '<td style="width:70px; padding:5px 10px 5px 0;">';
×
6414
                    $ret .= '<a href="' . $url . '" target="_blank"><img src="' . htmlspecialchars($image) . '" alt="" style="width:60px; height:60px; object-fit:cover;"></a>';
×
6415
                    $ret .= '</td>';
×
6416
                }
6417

6418
                # Text column - title and location.
6419
                $ret .= '<td style="padding:5px 0;">';
1✔
6420
                $ret .= '<a href="' . $url . '" target="_blank" style="color:#004085; font-weight:bold; text-decoration:none;">' . htmlentities($job['title']) . '</a>';
1✔
6421
                if ($loc && trim($loc) !== '') {
1✔
6422
                    $ret .= '<br><span style="color:#666; font-size:14px;">' . htmlentities($loc) . '</span>';
1✔
6423
                }
6424
                $ret .= '</td>';
1✔
6425

6426
                $ret .= '</tr>';
1✔
6427
            }
6428
        }
6429

6430
        $ret .= '</table>';
10✔
6431

6432
        return([
10✔
6433
            'location' => $search,
10✔
6434
            'jobs' => $ret
10✔
6435
        ]);
10✔
6436
    }
6437

6438
    public function updateModMails($uid = NULL) {
6439
        # We maintain a count of recent modmails by scanning logs regularly, and pruning old ones.  This means we can
6440
        # find the value in a well-indexed way without the disk overhead of having a two-column index on logs.
6441
        #
6442
        # Ignore logs where the user is the same as the byuser - for example a user can delete their own posts, and we are
6443
        # only interested in things where a mod has done something to another user.
6444
        $mysqltime = date("Y-m-d H:i:s", strtotime("10 minutes ago"));
1✔
6445
        $uidq = $uid ? " AND user = $uid " : '';
1✔
6446

6447
        $logs = $this->dbhr->preQuery("SELECT * FROM logs WHERE timestamp > ? AND ((type = 'Message' AND subtype IN ('Rejected', 'Deleted', 'Replied')) OR (type = 'User' AND subtype IN ('Mailed', 'Rejected', 'Deleted'))) AND byuser != user $uidq;", [
1✔
6448
            $mysqltime
1✔
6449
        ]);
1✔
6450

6451
        foreach ($logs as $log) {
1✔
6452
            $this->dbhm->preExec("INSERT IGNORE INTO users_modmails (userid, logid, timestamp, groupid) VALUES (?,?,?,?);", [
1✔
6453
                $log['user'],
1✔
6454
                $log['id'],
1✔
6455
                $log['timestamp'],
1✔
6456
                $log['groupid']
1✔
6457
            ]);
1✔
6458
        }
6459

6460
        # Prune old ones.
6461
        $mysqltime = date("Y-m-d", strtotime("Midnight 30 days ago"));
1✔
6462
        $uidq2 = $uid ? " AND userid = $uid " : '';
1✔
6463

6464
        $logs = $this->dbhr->preQuery("SELECT id FROM users_modmails WHERE timestamp < ? $uidq2;", [
1✔
6465
            $mysqltime
1✔
6466
        ]);
1✔
6467

6468
        foreach ($logs as $log) {
1✔
6469
            $this->dbhm->preExec("DELETE FROM users_modmails WHERE id = ?;", [ $log['id'] ], FALSE);
×
6470
        }
6471
    }
6472

6473
    public function getModGroupsByActivity() {
6474
        $start = date('Y-m-d', strtotime("60 days ago"));
1✔
6475
        $sql = "SELECT COUNT(*) AS count, CASE WHEN namefull IS NOT NULL THEN namefull ELSE nameshort END AS namedisplay FROM messages_groups INNER JOIN `groups` ON groups.id = messages_groups.groupid WHERE approvedby = ? AND arrival >= '$start' AND groups.publish = 1 AND groups.onmap = 1 AND groups.type = 'Freegle' GROUP BY groupid ORDER BY count DESC";
1✔
6476
        return $this->dbhr->preQuery($sql, [
1✔
6477
            $this->id
1✔
6478
        ]);
1✔
6479
    }
6480

6481
    public function related($userlist) {
6482
        $userlist = array_unique($userlist);
2✔
6483

6484
        foreach ($userlist as $user1) {
2✔
6485
            foreach ($userlist as $user2) {
2✔
6486
                if ($user1 && $user2 && $user1 !== $user2) {
2✔
6487
                    # We may be passed user ids which no longer exist.
6488
                    $u1 = User::get($this->dbhr, $this->dbhm, $user1);
2✔
6489
                    $u2 = User::get($this->dbhr, $this->dbhm, $user2);
2✔
6490

6491
                    if ($u1->getId() && $u2->getId() && !$u1->isAdminOrSupport() && !$u2->isAdminOrSupport()) {
2✔
6492
                        $this->dbhm->background("INSERT INTO users_related (user1, user2) VALUES ($user1, $user2) ON DUPLICATE KEY UPDATE timestamp = NOW();");
2✔
6493
                    }
6494
                }
6495
            }
6496
        }
6497
    }
6498

6499
    public function getRelated($userid, $since = "30 days ago") {
6500
        $starttime = date("Y-m-d H:i:s", strtotime($since));
1✔
6501
        $users = $this->dbhr->preQuery("SELECT * FROM users_related WHERE user1 = ? AND timestamp >= '$starttime';", [
1✔
6502
            $userid
1✔
6503
        ]);
1✔
6504

6505
        return ($users);
1✔
6506
    }
6507

6508
    public function listRelated($groupids, &$ctx, $limit = 10) {
6509
        # The < condition ensures we don't duplicate during a single run.
6510
        $limit = intval($limit);
1✔
6511
        $ret = [];
1✔
6512
        $backstop = 100;
1✔
6513

6514
        do {
6515
            $ctx = $ctx ? $ctx : [ 'id'  => NULL ];
1✔
6516

6517
            if ($groupids && count($groupids)) {
1✔
6518
                $ctxq = ($ctx && intval($ctx['id'])) ? (" WHERE id < " . intval($ctx['id'])) : '';
1✔
6519
                $groupq = "(" . implode(',', $groupids) . ")";
1✔
6520
                $sql = "SELECT DISTINCT id, user1, user2 FROM (
1✔
6521
SELECT users_related.id, user1, user2, memberships.groupid FROM users_related 
6522
INNER JOIN memberships ON users_related.user1 = memberships.userid 
6523
INNER JOIN users u1 ON users_related.user1 = u1.id AND u1.deleted IS NULL AND u1.systemrole = 'User'
6524
WHERE 
6525
user1 < user2 AND
6526
notified = 0 AND
6527
memberships.groupid IN $groupq UNION
1✔
6528
SELECT users_related.id, user1, user2, memberships.groupid FROM users_related 
6529
INNER JOIN memberships ON users_related.user2 = memberships.userid 
6530
INNER JOIN users u2 ON users_related.user2 = u2.id AND u2.deleted IS NULL AND u2.systemrole = 'User'
6531
WHERE 
6532
user1 < user2 AND
6533
notified = 0 AND
6534
memberships.groupid IN $groupq 
1✔
6535
) t $ctxq ORDER BY id DESC LIMIT $limit;";
1✔
6536
                $members = $this->dbhr->preQuery($sql);
1✔
6537
            } else {
6538
                $ctxq = ($ctx && intval($ctx['id'])) ? (" AND users_related.id < " . intval($ctx['id'])) : '';
1✔
6539
                $sql = "SELECT DISTINCT users_related.id, user1, user2 FROM users_related INNER JOIN users u1 ON u1.id = users_related.user1 AND u1.deleted IS NULL AND u1.systemrole = 'User' INNER JOIN users u2 ON u2.id = users_related.user2 AND u2.deleted IS NULL AND u2.systemrole = 'User' WHERE notified = 0 AND user1 < user2 $ctxq ORDER BY id DESC LIMIT $limit;";
1✔
6540
                $members = $this->dbhr->preQuery($sql, NULL, FALSE, FALSE);
1✔
6541
            }
6542

6543
            $uids1 = array_column($members, 'user1');
1✔
6544
            $uids2 = array_column($members, 'user2');
1✔
6545

6546
            $related = [];
1✔
6547
            foreach ($members as $member) {
1✔
6548
                $related[$member['user1']] = $member['user2'];
1✔
6549
                $ctx['id'] = $member['id'];
1✔
6550
            }
6551

6552
            $users = $this->getPublicsById(array_merge($uids1, $uids2));
1✔
6553

6554
            foreach ($users as &$user1) {
1✔
6555
                if (Utils::pres($user1['id'], $related)) {
1✔
6556
                    $thisone = $user1;
1✔
6557

6558
                    foreach ($users as $user2) {
1✔
6559
                        if ($user2['id'] == $related[$user1['id']]) {
1✔
6560
                            $user2['userid'] = $user2['id'];
1✔
6561
                            $thisone['relatedto'] = $user2;
1✔
6562
                            break;
1✔
6563
                        }
6564
                    }
6565

6566
                    $logins = $this->getLogins(FALSE, $thisone['id'], TRUE);
1✔
6567
                    $rellogins = $this->getLogins(FALSE, $thisone['relatedto']['id'], TRUE);
1✔
6568

6569
                    if ($thisone['deleted'] ||
1✔
6570
                        $thisone['relatedto']['deleted'] ||
1✔
6571
                        $thisone['systemrole'] != User::SYSTEMROLE_USER ||
1✔
6572
                        $thisone['relatedto']['systemrole'] != User::SYSTEMROLE_USER ||
1✔
6573
                        !count($logins) ||
1✔
6574
                        !count($rellogins)) {
1✔
6575
                        # No sense in telling people about these.
6576
                        #
6577
                        # If there are n valid login types for one of the users - no way they can log in again so no point notifying.
6578
                        $this->dbhm->preExec("UPDATE users_related SET notified = 1 WHERE (user1 = ? AND user2 = ?) OR (user1 = ? AND user2 = ?);", [
1✔
6579
                            $thisone['id'],
1✔
6580
                            $thisone['relatedto']['id'],
1✔
6581
                            $thisone['relatedto']['id'],
1✔
6582
                            $thisone['id']
1✔
6583
                        ]);
1✔
6584
                    } else {
6585
                        $thisone['userid'] = $thisone['id'];
1✔
6586
                        $thisone['logins'] = $logins;
1✔
6587
                        $thisone['relatedto']['logins'] = $rellogins;
1✔
6588

6589
                        $ret[] = $thisone;
1✔
6590
                    }
6591
                }
6592
            }
6593

6594
            $backstop--;
1✔
6595
        } while ($backstop > 0 && count($ret) < $limit && count($members));
1✔
6596

6597
        return $ret;
1✔
6598
    }
6599

6600
    public function getExpectedReplies($uids, $since = ChatRoom::ACTIVELIM, $grace = ChatRoom::REPLY_GRACE) {
6601
        # We count replies where the user has been active since the reply was requested, which means they've had
6602
        # a chance to reply, plus a grace period in minutes, so that if they're active right now we don't penalise them.
6603
        #
6604
        # $since here has to match the value in ChatRoom::
6605
        $starttime = date("Y-m-d H:i:s", strtotime($since));
130✔
6606
        $replies = $this->dbhr->preQuery("SELECT COUNT(*) AS count, expectee FROM users_expected INNER JOIN users ON users.id = users_expected.expectee INNER JOIN chat_messages ON chat_messages.id = users_expected.chatmsgid WHERE expectee IN (" . implode(',', $uids) . ") AND chat_messages.date >= '$starttime' AND replyexpected = 1 AND replyreceived = 0 AND TIMESTAMPDIFF(MINUTE, chat_messages.date, users.lastaccess) >= ?", [
130✔
6607
            $grace
130✔
6608
        ]);
130✔
6609

6610
        return($replies);
130✔
6611
    }
6612

6613
    public function listExpectedReplies($uid, $since = ChatRoom::ACTIVELIM, $grace = ChatRoom::REPLY_GRACE) {
6614
        # We count replies where the user has been active since the reply was requested, which means they've had
6615
        # a chance to reply, plus a grace period in minutes, so that if they're active right now we don't penalise them.
6616
        #
6617
        # $since here has to match the value in ChatRoom::
6618
        $starttime = date("Y-m-d H:i:s", strtotime($since));
20✔
6619
        $replies = $this->dbhr->preQuery("SELECT chatid FROM users_expected INNER JOIN users ON users.id = users_expected.expectee INNER JOIN chat_messages ON chat_messages.id = users_expected.chatmsgid WHERE expectee = ? AND chat_messages.date >= '$starttime' AND replyexpected = 1 AND replyreceived = 0 AND TIMESTAMPDIFF(MINUTE, chat_messages.date, users.lastaccess) > ?", [
20✔
6620
            $uid,
20✔
6621
            $grace
20✔
6622
        ]);
20✔
6623

6624
        $ret = [];
20✔
6625

6626
        if (count($replies)) {
20✔
6627
            $me = Session::whoAmI($this->dbhr, $this->dbhm);
1✔
6628
            $myid = $me ? $me->getId() : NULL;
1✔
6629

6630
            $r = new ChatRoom($this->dbhr, $this->dbhm);
1✔
6631
            $rooms = $r->fetchRooms(array_column($replies, 'chatid'), $myid, TRUE);
1✔
6632

6633
            foreach ($rooms as $room) {
1✔
6634
                $ret[] = [
1✔
6635
                    'id' => $room['id'],
1✔
6636
                    'name' => $room['name']
1✔
6637
                ];
1✔
6638
            }
6639
        }
6640

6641
        return $ret;
20✔
6642
    }
6643
    
6644
    public function getWorkCounts($groups = NULL) {
6645
        # Tell them what mod work there is.  Similar code in Notifications.
6646
        $ret = [];
28✔
6647
        $total = 0;
28✔
6648

6649
        $national = $this->hasPermission(User::PERM_NATIONAL_VOLUNTEERS);
28✔
6650

6651
        if ($national) {
28✔
6652
            $v = new Volunteering($this->dbhr, $this->dbhm);
1✔
6653
            $ret['pendingvolunteering'] = $v->systemWideCount();
1✔
6654
        }
6655

6656
        $s = new Spam($this->dbhr, $this->dbhm);
28✔
6657
        $spamcounts = $s->collectionCounts();
28✔
6658
        $ret['spammerpendingadd'] = $spamcounts[Spam::TYPE_PENDING_ADD];
28✔
6659
        $ret['spammerpendingremove'] = $spamcounts[Spam::TYPE_PENDING_REMOVE];
28✔
6660

6661
        if ($this->hasPermission(User::PERM_GIFTAID)) {
28✔
6662
            $d = new Donations($this->dbhr, $this->dbhm);
1✔
6663
            $ret['giftaid'] = $d->countGiftAidReview();
1✔
6664
        }
6665

6666
        if (!$groups) {
28✔
6667
            $groups = $this->getMemberships(FALSE, NULL, TRUE, TRUE, $this->id, FALSE);
14✔
6668
        }
6669

6670
        foreach ($groups as &$group) {
28✔
6671
            if (Utils::pres('work', $group)) {
22✔
6672
                foreach ($group['work'] as $key => $work) {
20✔
6673
                    if (Utils::pres($key, $ret)) {
20✔
6674
                        $ret[$key] += $work;
2✔
6675
                    } else {
6676
                        $ret[$key] = $work;
20✔
6677
                    }
6678
                }
6679
            }
6680
        }
6681

6682
        $s = new Story($this->dbhr, $this->dbhm);
28✔
6683
        $ret['stories'] = $s->getReviewCount(FALSE, $this, $groups);
28✔
6684
        $ret['newsletterstories'] = $this->hasPermission(User::PERM_NEWSLETTER) ? $s->getReviewCount(TRUE) : 0;
28✔
6685

6686
        // All the types of work which are worth nagging about.
6687
        $worktypes = [
28✔
6688
            'pendingvolunteering',
28✔
6689
            'chatreview',
28✔
6690
            'relatedmembers',
28✔
6691
            'stories',
28✔
6692
            'newsletterstories',
28✔
6693
            'pending',
28✔
6694
            'spam',
28✔
6695
            'pendingmembers',
28✔
6696
            'pendingevents',
28✔
6697
            'spammembers',
28✔
6698
            'editreview',
28✔
6699
            'pendingadmins'
28✔
6700
        ];
28✔
6701

6702
        if ($this->isAdminOrSupport()) {
28✔
6703
            $worktypes[] = 'spammerpendingadd';
1✔
6704
            $worktypes[] = 'spammerpendingremove';
1✔
6705
        }
6706

6707
        foreach ($worktypes as $key) {
28✔
6708
            $total += Utils::presdef($key, $ret, 0);
28✔
6709
        }
6710

6711
        $ret['total'] = $total;
28✔
6712

6713
        return $ret;
28✔
6714
    }
6715

6716
    public function ratingVisibility($since = "1 hour ago") {
6717
        $mysqltime = date("Y-m-d", strtotime($since));
1✔
6718

6719
        $ratings = $this->dbhr->preQuery("SELECT * FROM ratings WHERE timestamp >= ?;", [
1✔
6720
            $mysqltime
1✔
6721
        ]);
1✔
6722

6723
        foreach ($ratings as $rating) {
1✔
6724
            # A rating is visible to others if there is a chat between the two members, and
6725
            # - the ratee replied to a post, or
6726
            # - there is at least one message from each of them.
6727
            # This means that has been an exchange substantial enough for the rating not to just be frivolous.  It
6728
            # deliberately excludes interactions on ChitChat, where we have seen some people go a bit overboard on
6729
            # rating people.
6730
            $visible = FALSE;
1✔
6731
            #error_log("Check {$rating['rater']} rating of {$rating['ratee']}");
6732

6733
            $chats = $this->dbhr->preQuery("SELECT id FROM chat_rooms WHERE (user1 = ? AND user2 = ?) OR (user2 = ? AND user1 = ?)", [
1✔
6734
                $rating['rater'],
1✔
6735
                $rating['ratee'],
1✔
6736
                $rating['rater'],
1✔
6737
                $rating['ratee'],
1✔
6738
            ]);
1✔
6739

6740
            foreach ($chats as $chat) {
1✔
6741
                $distincts = $this->dbhr->preQuery("SELECT COUNT(DISTINCT(userid)) AS count FROM chat_messages WHERE chatid = ? AND refmsgid IS NULL AND message IS NOT NULL;", [
1✔
6742
                    $chat['id']
1✔
6743
                ]);
1✔
6744

6745
                if ($distincts[0]['count'] >= 2) {
1✔
6746
                    #error_log("At least one real message from each of them in {$chat['id']}");
6747
                    $visible = TRUE;
1✔
6748
                } else {
6749
                    $replies = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM chat_messages WHERE chatid = ? AND userid = ? AND refmsgid IS NOT NULL AND message IS NOT NULL;", [
1✔
6750
                        $chat['id'],
1✔
6751
                        $rating['ratee']
1✔
6752
                    ]);
1✔
6753

6754
                    if ($replies[0]['count']) {
1✔
6755
                        #error_log("Significant reply from {$rating['ratee']} in {$chat['id']}");
6756
                        $visible = TRUE;
1✔
6757
                    }
6758
                }
6759
            }
6760

6761
            #error_log("Use {$rating['rating']} from {$rating['rater']} ? " . ($visible ? 'yes': 'no'));
6762
            $oldvisible = intval($rating['visible']) ? TRUE : FALSE;
1✔
6763

6764
            if ($visible != $oldvisible) {
1✔
6765
                $this->dbhm->preExec("UPDATE ratings SET visible = ?, timestamp = NOW() WHERE id = ?;", [
1✔
6766
                    $visible,
1✔
6767
                    $rating['id']
1✔
6768
                ]);
1✔
6769
            }
6770
        }
6771
    }
6772

6773
    public function unban($groupid) {
6774
        $this->dbhm->preExec("DELETE FROM users_banned WHERE userid = ? AND groupid = ?;", [
4✔
6775
            $this->id,
4✔
6776
            $groupid
4✔
6777
        ]);
4✔
6778
    }
6779

6780
    public function hasFacebookLogin() {
6781
        $logins = $this->getLogins();
3✔
6782
        $ret = FALSE;
3✔
6783

6784
        foreach ($logins as $login) {
3✔
6785
            if ($login['type'] == User::LOGIN_FACEBOOK) {
3✔
6786
                $ret = TRUE;
×
6787
            }
6788
        }
6789

6790
        return $ret;
3✔
6791
    }
6792

6793
    public function memberReview($groupid, $request, $reason) {
6794
        $mysqltime = date('Y-m-d H:i');
6✔
6795

6796
        if ($request) {
6✔
6797
            # Requesting review.  Leave reviewedat unchanged, so that we can use it to avoid asking too
6798
            # frequently.
6799
            $this->setMembershipAtt($groupid, 'reviewreason', $reason);
4✔
6800
            $this->setMembershipAtt($groupid, 'reviewrequestedat', $mysqltime);
4✔
6801
        } else {
6802
            # We have reviewed.  Note that they might have been removed, in which case the set will do nothing.
6803
            $this->setMembershipAtt($groupid, 'reviewrequestedat', NULL);
3✔
6804
            $this->setMembershipAtt($groupid, 'reviewedat', $mysqltime);
3✔
6805
        }
6806
    }
6807

6808
    private function checkSupporterSettings($settings) {
6809
        $ret = TRUE;
77✔
6810

6811
        if ($settings) {
77✔
6812
            $s = json_decode($settings, TRUE);
15✔
6813

6814
            if ($s && array_key_exists('hidesupporter', $s)) {
15✔
6815
                $ret = !$s['hidesupporter'];
1✔
6816
            }
6817
        }
6818

6819
        return $ret;
77✔
6820
    }
6821

6822
    public function getSupporters(&$rets, $users) {
6823
        $idsleft = [];
278✔
6824

6825
        foreach ($rets as $userid => $ret) {
278✔
6826
            if (Utils::pres($userid, $users)) {
237✔
6827
                if (array_key_exists('supporter', $users[$userid])) {
11✔
6828
                    $rets[$userid]['supporter'] = $users[$userid]['supporter'];
3✔
6829
                    $rets[$userid]['donor'] = Utils::presdef('donor', $users[$userid], FALSE);
11✔
6830
                }
6831
            } else {
6832
                $idsleft[] = $userid;
232✔
6833
            }
6834
        }
6835

6836
        if (count($idsleft)) {
278✔
6837
            $me = Session::whoAmI($this->dbhr, $this->dbhm);
232✔
6838
            $myid = $me ? $me->getId() : null;
232✔
6839

6840
            # A supporter is a mod, someone who has donated recently, or done microvolunteering recently.
6841
            if (count($idsleft)) {
232✔
6842
                $start = date('Y-m-d', strtotime("360 days ago"));
232✔
6843
                $info = $this->dbhr->preQuery(
232✔
6844
                    "SELECT DISTINCT users.id AS userid, settings, systemrole FROM users 
232✔
6845
    LEFT JOIN microactions ON users.id = microactions.userid
6846
    LEFT JOIN users_donations ON users_donations.userid = users.id 
6847
    WHERE users.id IN (" . implode(
232✔
6848
                        ',',
232✔
6849
                        $idsleft
232✔
6850
                    ) . ") AND 
232✔
6851
                    (systemrole IN (?, ?, ?) OR microactions.timestamp >= ? OR users_donations.timestamp >= ?);",
232✔
6852
                    [
232✔
6853
                        User::SYSTEMROLE_ADMIN,
232✔
6854
                        User::SYSTEMROLE_SUPPORT,
232✔
6855
                        User::SYSTEMROLE_MODERATOR,
232✔
6856
                        $start,
232✔
6857
                        $start
232✔
6858
                    ]
232✔
6859
                );
232✔
6860

6861
                $found = [];
232✔
6862

6863
                foreach ($info as $i) {
232✔
6864
                    $rets[$i['userid']]['supporter'] = $this->checkSupporterSettings($i['settings']);
77✔
6865
                    $found[] = $i['userid'];
77✔
6866
                }
6867

6868
                $left = array_diff($idsleft, $found);
232✔
6869

6870
                # If we are one of the users, then we want to return whether we are a donor.
6871
                if (in_array($myid, $idsleft)) {
232✔
6872
                    $left[] = $myid;
143✔
6873
                    $left = array_filter(array_unique($left));
143✔
6874
                }
6875

6876
                if (count($left)) {
232✔
6877
                    $info = $this->dbhr->preQuery(
230✔
6878
                        "SELECT userid, settings, TransactionType FROM users_donations INNER JOIN users ON users_donations.userid = users.id WHERE users_donations.timestamp >= ? AND users_donations.userid IN (" . implode(
230✔
6879
                            ',',
230✔
6880
                            $left
230✔
6881
                        ) . ") GROUP BY TransactionType;",
230✔
6882
                        [
230✔
6883
                            $start
230✔
6884
                        ]
230✔
6885
                    );
230✔
6886

6887
                    foreach ($info as $i) {
230✔
6888
                        $rets[$i['userid']]['supporter'] = $this->checkSupporterSettings($i['settings']);
3✔
6889

6890
                        if ($i['userid'] == $myid) {
3✔
6891
                            # Only return this info for ourselves, otherwise it's a privacy leak.
6892
                            $rets[$i['userid']]['donor'] = TRUE;
3✔
6893

6894
                            if ($i['TransactionType'] == 'subscr_payment' || $i['TransactionType'] == 'recurring_payment') {
3✔
6895
                                $rets[$i['userid']]['donorrecurring'] = TRUE;
1✔
6896
                            }
6897
                        }
6898
                    }
6899
                }
6900
            }
6901
        }
6902
    }
6903

6904
    public function obfuscateEmail($email) {
6905
        $p = strpos($email, '@');
2✔
6906
        $q = strpos($email, 'privaterelay.appleid.com');
2✔
6907

6908
        if ($q) {
2✔
6909
            $email = 'Your Apple ID';
1✔
6910
        } else {
6911
            # For very short emails, we just show the first character.
6912
            if ($p <= 3) {
2✔
6913
                $email = substr($email, 0, 1) . "***" . substr($email, $p);
1✔
6914
            } else if ($p < 10) {
2✔
6915
                $email = substr($email, 0, 1) . "***" . substr($email, $p - 1);
2✔
6916
            } else {
6917
                $email = substr($email, 0, 3) . "***" . substr($email, $p - 3);
1✔
6918
            }
6919
        }
6920

6921
        return $email;
2✔
6922
    }
6923

6924
    public function setSimpleMail($simplemail) {
6925
        $s = $this->getPrivate('settings');
2✔
6926

6927
        if ($s) {
2✔
6928
            $settings = json_decode($s, TRUE);
1✔
6929
        } else {
6930
            $settings = [];
2✔
6931
        }
6932

6933
        $this->dbhm->beginTransaction();
2✔
6934

6935
        switch ($simplemail) {
6936
            case User::SIMPLE_MAIL_NONE: {
6937
                # No digests, no events/volunteering.
6938
                # No relevant or newsletters.
6939
                # No email notifications.
6940
                # No enagement.
6941
                $this->dbhm->preExec("UPDATE memberships SET emailfrequency = 0, eventsallowed = 0, volunteeringallowed = 0 WHERE userid = ?;", [
2✔
6942
                    $this->id
2✔
6943
                ]);
2✔
6944

6945
                $this->dbhm->preExec("UPDATE users SET relevantallowed = 0,  newslettersallowed = 0 WHERE id = ?;", [
2✔
6946
                    $this->id
2✔
6947
                ]);
2✔
6948

6949
                $settings['notifications']['email'] = FALSE;
2✔
6950
                $settings['notifications']['emailmine'] = FALSE;
2✔
6951
                $settings['notificationmails']= FALSE;
2✔
6952
                $settings['engagement'] = FALSE;
2✔
6953
                break;
2✔
6954
            }
6955
            case User::SIMPLE_MAIL_BASIC: {
6956
                # Daily digests, no events/volunteering.
6957
                # No relevant or newsletters.
6958
                # Chat email notifications.
6959
                # No enagement.
6960
                $this->dbhm->preExec("UPDATE memberships SET emailfrequency = 24, eventsallowed = 0, volunteeringallowed = 0 WHERE userid = ?;", [
1✔
6961
                    $this->id
1✔
6962
                ]);
1✔
6963

6964
                $this->dbhm->preExec("UPDATE users SET relevantallowed = 0,  newslettersallowed = 0 WHERE id = ?;", [
1✔
6965
                    $this->id
1✔
6966
                ]);
1✔
6967

6968
                $settings['notifications']['email'] = TRUE;
1✔
6969
                $settings['notifications']['emailmine'] = FALSE;
1✔
6970
                $settings['notificationmails']= FALSE;
1✔
6971
                $settings['engagement']= FALSE;
1✔
6972
                break;
1✔
6973
            }
6974
            case User::SIMPLE_MAIL_FULL: {
6975
                # Immediate mails, events/volunteering.
6976
                # Relevant and newsletters.
6977
                # Email notifications.
6978
                # Enagement.
6979
                $this->dbhm->preExec("UPDATE memberships SET emailfrequency = -1, eventsallowed = 1, volunteeringallowed = 1 WHERE userid = ?;", [
1✔
6980
                    $this->id
1✔
6981
                ]);
1✔
6982

6983
                $this->dbhm->preExec("UPDATE users SET relevantallowed = 1,  newslettersallowed = 1 WHERE id = ?;", [
1✔
6984
                    $this->id
1✔
6985
                ]);
1✔
6986

6987
                $settings['notifications']['email'] = TRUE;
1✔
6988
                $settings['notifications']['emailmine'] = FALSE;
1✔
6989
                $settings['notificationmails']= TRUE;
1✔
6990
                $settings['engagement']= TRUE;
1✔
6991
                break;
1✔
6992
            }
6993
        }
6994

6995
        $settings['simplemail'] = $simplemail;
2✔
6996

6997
        $this->setPrivate('settings', json_encode($settings));
2✔
6998

6999
        # Holiday no longer exposed so turn off.
7000
        $this->dbhm->preExec("UPDATE users SET onholidaytill = NULL, settings = ? WHERE id = ?;", [
2✔
7001
            json_encode($settings),
2✔
7002
            $this->id
2✔
7003
        ]);
2✔
7004

7005
        $this->dbhm->commit();
2✔
7006
    }
7007

7008
    public function processMemberships($g = NULL) {
7009
        $memberships = $this->dbhr->preQuery("SELECT id FROM `memberships_history` WHERE processingrequired = 1 ORDER BY id ASC;");
6✔
7010

7011
        foreach ($memberships as $membership) {
6✔
7012
            $this->processMembership($membership['id'], $g);
6✔
7013
        }
7014
    }
7015

7016
    public function processMembership($id, $g) {
7017
        $memberships = $this->dbhr->preQuery("SELECT * FROM memberships_history WHERE id = ?;",[
6✔
7018
            $id
6✔
7019
        ]);
6✔
7020

7021
        foreach ($memberships as $membership) {
6✔
7022
            $groupid = $membership['groupid'];
6✔
7023
            $userid = $membership['userid'];
6✔
7024
            $collection = $membership['collection'];
6✔
7025

7026
            $g = $g ? $g : Group::get($this->dbhr, $this->dbhm, $groupid);
6✔
7027

7028
            # The membership didn't already exist.  We might want to send a welcome mail.
7029
            if ($g->getPrivate('welcomemail') && $collection == MembershipCollection::APPROVED && $g->getPrivate('onhere')) {
6✔
7030
                # They are now approved.  We need to send a per-group welcome mail.
7031
                try {
7032
                    $g->sendWelcome($userid, FALSE);
2✔
7033
                } catch (Exception $e) {
×
7034
                    error_log("Welcome failed: " . $e->getMessage());
×
7035
                    \Sentry\captureException($e);
×
7036
                }
7037
            }
7038

7039
            # Check whether this user now counts as a possible spammer.
7040
            $s = new Spam($this->dbhr, $this->dbhm);
6✔
7041
            $s->checkUser($userid, $groupid);
6✔
7042

7043
            # We might have mod notes which require this member to be flagged up.
7044
            $comments = $this->dbhr->preQuery(
6✔
7045
                "SELECT COUNT(*) AS count FROM users_comments WHERE userid = ? AND flag = 1;", [
6✔
7046
                    $userid,
6✔
7047
                ]
6✔
7048
            );
6✔
7049

7050
            if ($comments[0]['count'] > 0) {
6✔
7051
                $this->memberReview($groupid, TRUE, 'Note flagged to other groups');
1✔
7052
            }
7053

7054
            $this->dbhm->preExec("UPDATE memberships_history SET processingrequired = 0 WHERE id = ?", [
6✔
7055
                $id
6✔
7056
            ]);
6✔
7057
        }
7058
    }
7059

7060
    public function getUserKey($id) {
7061
        $key = null;
76✔
7062
        $sql = "SELECT * FROM users_logins WHERE userid = ? AND type = ?;";
76✔
7063
        $logins = $this->dbhr->preQuery($sql, [$id, User::LOGIN_LINK]);
76✔
7064
        foreach ($logins as $login) {
76✔
7065
            $key = $login['credentials'];
24✔
7066
        }
7067

7068
        if (!$key) {
76✔
7069
            $key = Utils::randstr(32);
75✔
7070
            $rc = $this->dbhm->preExec("INSERT INTO users_logins (userid, type, credentials) VALUES (?,?,?);", [
75✔
7071
                $id,
75✔
7072
                User::LOGIN_LINK,
75✔
7073
                $key
75✔
7074
            ]);
75✔
7075
        }
7076

7077
        return $key;
75✔
7078
    }
7079

7080
    public function assignUserToToDonation($email, $userid) {
7081
        $email = trim($email);
518✔
7082

7083
        if (strlen($email)) {
518✔
7084
            # We might have donations made via PayPal using this email address which we can now link to this user.  Do
7085
            # SELECT first to avoid this having to replicate in the cluster.
7086
            $donations = $this->dbhr->preQuery("SELECT id FROM users_donations WHERE Payer = ? AND userid IS NULL;", [
518✔
7087
                $email
518✔
7088
            ]);
518✔
7089

7090
            foreach ($donations as $donation) {
518✔
7091
                // Check if user exists before updating to avoid foreign key constraint violations
7092
                $userExists = $this->dbhr->preQuery("SELECT id FROM users WHERE id = ?;", [$userid]);
35✔
7093
                if (count($userExists) > 0) {
35✔
7094
                    $this->dbhm->preExec("UPDATE users_donations SET userid = ? WHERE id = ?;", [
35✔
7095
                        $userid,
35✔
7096
                        $donation['id']
35✔
7097
                    ]);
35✔
7098
                }
7099
            }
7100
        }
7101
    }
7102
}
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