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

Freegle / iznik-server / #2567

12 Jan 2026 06:33AM UTC coverage: 87.991% (-0.02%) from 88.008%
#2567

push

edwh
Fix illustration scripts getting stuck in infinite loop on failing items

Changes:
- fetchBatch() now returns partial results instead of FALSE when individual
  items fail to return data (only true rate-limiting fails the batch)
- Add file-based tracking for items that repeatedly fail (/tmp/pollinations_failed.json)
- Items that fail 3 times are skipped for 1 day before retrying
- Both messages_illustrations.php and jobs_illustrations.php updated to:
  - Skip items that have exceeded failure threshold
  - Record failures for items that don't return data
  - Process successful results from partial batches

This prevents a single problematic item (like "2 toilet seat trainers") from
blocking all illustration generation indefinitely.

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

0 of 47 new or added lines in 1 file covered. (0.0%)

114 existing lines in 3 files now uncovered.

26298 of 29887 relevant lines covered (87.99%)

31.48 hits per line

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

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

4
require_once(IZNIK_BASE . '/mailtemplates/invite.php');
53✔
5
require_once(IZNIK_BASE . '/lib/wordle/functions.php');
53✔
6
require_once(IZNIK_BASE . '/lib/GreatCircle.php');
53✔
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);
647✔
136
        $this->notif = new PushNotifications($dbhr, $dbhm);
647✔
137
        $this->dbhr = $dbhr;
647✔
138
        $this->dbhm = $dbhm;
647✔
139
        $this->name = 'user';
647✔
140
        $this->user = NULL;
647✔
141
        $this->id = NULL;
647✔
142
        $this->table = 'users';
647✔
143
        $this->spammer = [];
647✔
144

145
        if ($id) {
647✔
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 = ?;";
608✔
148

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

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

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

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

172
                if (!User::$cacheDeleted[$id]) {
572✔
173
                    # And it's not zapped - so we can use it.
174
                    #error_log("Not zapped");
175
                    return ($u);
565✔
176
                } else if (!$testonly) {
448✔
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);
447✔
182
                    #error_log("Fetched $id as " . $u->getId() . " mod " . $u->isModerator());
183

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

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

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

204
        return ($u);
639✔
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) {
788✔
212
            User::$cacheDeleted[$id] = TRUE;
561✔
213
        } else {
214
            User::$cache = [];
788✔
215
            User::$cacheDeleted = [];
788✔
216
        }
217
    }
218

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

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

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

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

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

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

250
                        return (TRUE);
290✔
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;
608✔
303

304
        $name = NULL;
608✔
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)) {
608✔
309
            $name = $atts['fullname'];
506✔
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;
608✔
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;
608✔
322

323
        if ($default &&
608✔
324
            $this->id &&
608✔
325
            (strlen(trim($name)) === 0 ||
608✔
326
                $name == 'A freegler' ||
608✔
327
                $resurrect ||
608✔
328
                (strlen($name) == 32 && preg_match('/[A-Za-z].*[0-9]|[0-9].*[A-Za-z]/', $name)) ||
608✔
329
                strpos($name, 'FBUser') !== FALSE)
608✔
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;
608✔
345

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

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

352
        return ($name);
608✔
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);
609✔
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);
608✔
371

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

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

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

396
            return ($id);
607✔
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';
391✔
435

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

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

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

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

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

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

464
        return ($ret);
179✔
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();
378✔
475
        $ret = NULL;
378✔
476

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

486
        return ($ret);
378✔
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]);
328✔
515
        return (count($membs) > 0 ? $membs[0]['id'] : NULL);
328✔
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];
259✔
573
    }
574

575
    public function findByEmailIncludingUnvalidated($email)
576
    {
577
        if (preg_match('/.*\-(.*)\@' . USER_DOMAIN . '/', $email, $matches)) {
283✔
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;",
283✔
594
            [
283✔
595
                $email,
283✔
596
                User::canonMail($email),
283✔
597
            ]);
283✔
598

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

608
        return [ NULL, FALSE ];
138✔
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);
539✔
643
        $email = str_replace('@googlemail.co.uk', '@gmail.co.uk', $email);
539✔
644

645
        # Canonicalise TN addresses.
646
        if (preg_match('/(.*)\-(.*)(@user.trashnothing.com)/', $email, $matches)) {
539✔
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) {
539✔
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, '@');
539✔
661

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

666
            if (stripos($rhs, '@gmail') !== FALSE || stripos($rhs, '@googlemail') !== FALSE) {
539✔
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);
539✔
673
        }
674

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

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

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

685
        if (stripos($email, '-owner@yahoogroups.co') !== FALSE ||
540✔
686
            stripos($email, '-volunteers@' . GROUP_DOMAIN) !== FALSE ||
539✔
687
            stripos($email, '-auto@' . GROUP_DOMAIN) !== FALSE)
540✔
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) {
539✔
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);
538✔
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 = ?;";
538✔
703
            $emails = $this->dbhm->preQuery($sql, [
538✔
704
                $this->id,
538✔
705
                $email
538✔
706
            ]);
538✔
707

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

714
                if ($rc && $primary) {
538✔
715
                    # Make sure no other email is flagged as primary
716
                    $this->dbhm->preExec("UPDATE users_emails SET preferred = 0 WHERE userid = ? AND id != ?;", [
538✔
717
                        $this->id,
538✔
718
                        $rc
538✔
719
                    ]);
538✔
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);
540✔
746

747
        return ($rc);
540✔
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);
477✔
787

788
        if ($role == User::ROLE_MODERATOR || $role == User::ROLE_OWNER) {
477✔
789
            $sql = "UPDATE users SET systemrole = ? WHERE id = ? AND systemrole = ?;";
159✔
790
            $this->dbhm->preExec($sql, [User::SYSTEMROLE_MODERATOR, $this->id, User::SYSTEMROLE_USER]);
159✔
791
            $this->user['systemrole'] = $this->user['systemrole'] == User::SYSTEMROLE_USER ?
159✔
792
                User::SYSTEMROLE_MODERATOR : $this->user['systemrole'];
159✔
793
        } else if ($this->user['systemrole'] == User::SYSTEMROLE_MODERATOR) {
462✔
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 = ?;";
477✔
825
        $banneds = $this->dbhr->preQuery($sql, [
477✔
826
            $this->id,
477✔
827
            $groupid
477✔
828
        ]);
477✔
829

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

834
        return FALSE;
477✔
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;
477✔
840
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
477✔
841
        $g = $g ? $g : Group::get($this->dbhr, $this->dbhm, $groupid);
477✔
842

843
        Session::clearSessionCache();
477✔
844

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

850
        $existing = $this->dbhm->preQuery("SELECT COUNT(*) AS count FROM memberships WHERE userid = ? AND groupid = ? AND collection = ?;", [
477✔
851
            $this->id,
477✔
852
            $groupid,
477✔
853
            $collection
477✔
854
        ]);
477✔
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);
477✔
860

861
        $emailfrequency = 24;
477✔
862
        $eventsallowed = 1;
477✔
863
        $volunteeringallowed = 1;
477✔
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 = ?;", [
477✔
889
            $this->id,
477✔
890
            $groupid,
477✔
891
            $role,
477✔
892
            $collection,
477✔
893
            $emailfrequency,
477✔
894
            $eventsallowed,
477✔
895
            $volunteeringallowed,
477✔
896
            $role,
477✔
897
            $collection,
477✔
898
            $emailfrequency,
477✔
899
            $eventsallowed,
477✔
900
            $volunteeringallowed
477✔
901
        ]);
477✔
902

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

905
        # We added it if it wasn't there before and the INSERT worked.
906
        $added = $this->dbhm->rowsAffected() && $existing[0]['count'] == 0;
477✔
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 (?,?,?,?);", [
477✔
911
            $this->id,
477✔
912
            $groupid,
477✔
913
            $collection,
477✔
914
            $added
477✔
915
        ]);
477✔
916

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

919
        # We might need to update the systemrole.
920
        #
921
        # Not the end of the world if this fails.
922
        $this->updateSystemRole($role);
477✔
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) {
477✔
942
            $l = new Log($this->dbhr, $this->dbhm);
477✔
943
            $text = NULL;
477✔
944

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

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

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

962
    public function cacheMemberships($id = NULL)
963
    {
964
        $id = $id ? $id : $this->id;
363✔
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) {
363✔
969
            $this->memberships = [];
363✔
970

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

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

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

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

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

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

1007
        return ($rc);
249✔
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;
163✔
1117

1118
        $ret = [];
163✔
1119
        $modq = $modonly ? " AND role IN ('Owner', 'Moderator') " : "";
163✔
1120
        $typeq = $grouptype ? (" AND `type` = " . $this->dbhr->quote($grouptype)) : '';
163✔
1121
        $publishq = Session::modtools() ? "" : "AND groups.publish = 1";
163✔
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;";
163✔
1123
        $groups = $this->dbhr->preQuery($sql, [$id]);
163✔
1124
        #error_log("getMemberships $sql {$id} " . var_export($groups, TRUE));
1125

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

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

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

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

1145
            if ($amod && !Utils::pres('configid', $one)) {
127✔
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);
127✔
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 &&
127✔
1156
                ($pernickety || $this->sendOurMails($g, FALSE, FALSE))) ?
127✔
1157
                (array_key_exists('emailfrequency', $one['mysettings']) ? $one['mysettings']['emailfrequency'] :  24)
88✔
1158
                : 0;
40✔
1159

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

1162
            if ($getwork) {
127✔
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;
127✔
1171
        }
1172

1173
        if ($getwork) {
163✔
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);
163✔
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);
149✔
1283
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
149✔
1284

1285
        $ret = [];
149✔
1286
        foreach ($this->memberships AS $membership) {
149✔
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);
149✔
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 . "'") : '';
302✔
1327

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

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

1340
        return ($logins);
302✔
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) {
514✔
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);
512✔
1361
            $uid = $this->id;
512✔
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 = ?;";
514✔
1366
        $rc = $this->dbhm->preExec($sql,
514✔
1367
            [$this->id, $uid, $type, $creds, $salt, $creds, $salt]);
514✔
1368

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

1374
        return ($rc);
514✔
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')) {
33✔
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;
35✔
1417
                    break;
35✔
1418
                case 'Owner':
62✔
1419
                    $role = User::ROLE_OWNER;
8✔
1420
                    break;
8✔
1421
                case 'Member':
60✔
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;
60✔
1424
                    break;
60✔
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;
481✔
1462
        $s = $this->getPrivate('settings');
481✔
1463

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

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

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

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

1482
        $settings[$setting] = $val;
29✔
1483
        $this->setPrivate('settings', json_encode($settings));
29✔
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);
38✔
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']);
38✔
1505
        return ($active);
38✔
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();
21✔
1511
        $widerreview = FALSE;
21✔
1512

1513
        foreach ($modships as $mod) {
21✔
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;
21✔
1524
    }
1525

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

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

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

1542
        $settings = $defaults;
158✔
1543

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

1547
            if ($set['settings']) {
158✔
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']);
158✔
1560
            $settings['active'] = $settings['active'] ? 1 : 0;
158✔
1561

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

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

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

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

1581
        Session::clearSessionCache();
46✔
1582

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

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

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

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

1609
            if ($currentRole == User::ROLE_MEMBER) {
46✔
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) {
16✔
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;
46✔
1624
        }
1625

1626
        return ($rc);
46✔
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'));
131✔
1673

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

1678
        foreach ($uids as $uid) {
131✔
1679
            $users[$uid]['info']['replies'] = 0;
131✔
1680
            $users[$uid]['info']['taken'] = 0;
131✔
1681
            $users[$uid]['info']['reneged'] = 0;
131✔
1682
            $users[$uid]['info']['collected'] = 0;
131✔
1683
            $users[$uid]['info']['openage'] = User::OPEN_AGE;
131✔
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.*" : '';
131✔
1691
        $sql = "SELECT t0.id AS theuserid, t0.lastaccess AS lastaccess, t1.*, t3.*, t4.*, t5.* $tq FROM
131✔
1692
(SELECT id, lastaccess FROM users WHERE id in (" . implode(',', $uids) . ")) t0 LEFT JOIN                                                                
131✔
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";
131✔
1694

1695
        if (Session::modtools()) {
131✔
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 ";
131✔
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 ";
131✔
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
131✔
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
131✔
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
131✔
1703
;";
131✔
1704
        $counts = $this->dbhr->preQuery($sql, [
131✔
1705
            $start,
131✔
1706
            ChatMessage::TYPE_INTERESTED,
131✔
1707
            $start,
131✔
1708
            Message::TYPE_OFFER,
131✔
1709
            ChatMessage::TYPE_INTERESTED
131✔
1710
        ]);
131✔
1711

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

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

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

1727
                    if (Utils::pres('abouttime', $count)) {
131✔
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;";
131✔
1738
        $counts = $this->dbhr->preQuery($sql, [
131✔
1739
            $start,
131✔
1740
            MessageCollection::APPROVED
131✔
1741
        ]);
131✔
1742

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

1750
            foreach ($counts as $count) {
131✔
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);
131✔
1771

1772
        if ($me) {
131✔
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);
131✔
1789
        $replytimes = $r->replyTimes($uids);
131✔
1790

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

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

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

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

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

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

1809
        foreach ($replies as $reply) {
131✔
1810
            if ($reply['expectee']) {
131✔
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;
47✔
1831

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

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

1843
        return($ret);
47✔
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 = [];
259✔
2030

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

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

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

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

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

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

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

2061
            if (Utils::presdef('settings', $user, NULL)) {
264✔
2062
                # This is a bit of a type guddle.
2063
                if (gettype($user['settings']) == 'string') {
36✔
2064
                    $rets[$user['id']]['settings'] = json_decode($user['settings'], TRUE);
35✔
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'])) {
264✔
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;
264✔
2076
            $rets[$user['id']]['settings']['engagement'] = array_key_exists('engagement', $rets[$user['id']]['settings']) ? $rets[$user['id']]['settings']['engagement'] : TRUE;
264✔
2077
            $rets[$user['id']]['settings']['modnotifs'] = array_key_exists('modnotifs', $rets[$user['id']]['settings']) ? $rets[$user['id']]['settings']['modnotifs'] : 4;
264✔
2078
            $rets[$user['id']]['settings']['backupmodnotifs'] = array_key_exists('backupmodnotifs', $rets[$user['id']]['settings']) ? $rets[$user['id']]['settings']['backupmodnotifs'] : 12;
264✔
2079

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

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

2084
            foreach (['fullname', 'firstname', 'lastname'] as $att) {
264✔
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) {
264✔
2087
                    $value = $rets[$user['id']][$att];
264✔
2088
                    $rets[$user['id']][$att] = $value && strpos($value, '@') !== FALSE ? substr($value, 0, strpos($value, '@')) : $value;
264✔
2089
                }
2090
            }
2091

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

2100
            if ($me && ($me->isModerator() || $user['id'] == $me->getId())) {
264✔
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;
165✔
2103
            } else {
2104
                # Don't show some attributes unless they're a mod or ourselves.
2105
                $ismod = $rets[$user['id']]['systemrole'] == User::SYSTEMROLE_ADMIN ||
143✔
2106
                    $rets[$user['id']]['systemrole'] == User::SYSTEMROLE_SUPPORT ||
143✔
2107
                    $rets[$user['id']]['systemrole'] == User::SYSTEMROLE_MODERATOR;
143✔
2108
                $showmod = $ismod && Utils::presdef('showmod', $rets[$user['id']]['settings'], FALSE);
143✔
2109
                $rets[$user['id']]['settings']['showmod'] = $showmod;
143✔
2110
                $rets[$user['id']]['yahooid'] = NULL;
143✔
2111
            }
2112

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

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

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

2138
        if (count($idsleft)) {
265✔
2139
            foreach ($idsleft as $id) {
265✔
2140
                $rets[$id]['profile'] = [
265✔
2141
                    'url' => 'https://' . IMAGE_DOMAIN . '/defaultprofile.png',
265✔
2142
                    'turl' => 'https://' . IMAGE_DOMAIN . '/defaultprofile.png',
265✔
2143
                    'default' => TRUE
265✔
2144
                ];
265✔
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;";
265✔
2149

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

2152
            foreach ($profiles as $profile) {
265✔
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 = [];
249✔
2258

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

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

2268
        if ($memberof &&
249✔
2269
            count($userids) &&
249✔
2270
            ($systemrole == User::ROLE_MODERATOR || $systemrole == User::SYSTEMROLE_ADMIN || $systemrole == User::SYSTEMROLE_SUPPORT)
249✔
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'];
6✔
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 &&
249✔
2339
            $systemrole == User::ROLE_MODERATOR ||
189✔
2340
            $systemrole == User::SYSTEMROLE_ADMIN ||
217✔
2341
            $systemrole == User::SYSTEMROLE_SUPPORT
249✔
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;
249✔
2417
        $userids = array_filter(array_keys($rets), function($val) use ($myid) {
249✔
2418
            return($val != $myid);
249✔
2419
        });
249✔
2420

2421
        if (count($userids)) {
249✔
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) . ");";
170✔
2424

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

2427
            foreach ($rets as &$ret) {
170✔
2428
                foreach ($users as &$user) {
170✔
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 = [];
142✔
2469

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

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

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

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

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

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

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

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

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

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

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

2516
        foreach ($rets as &$ret) {
96✔
2517
            if (Utils::pres($ret['id'], $emails)) {
95✔
2518
                $ret['emails'] = $emails[$ret['id']];
79✔
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);
264✔
2716
        $systemrole = $me ? $me->getPrivate('systemrole') : User::SYSTEMROLE_USER;
264✔
2717
        $freeglemod = $me && $me->isFreegleMod();
264✔
2718

2719
        $rets = [];
264✔
2720

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

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

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

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

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

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

2747
        return ($rets);
264✔
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 ||
268✔
2771
            $this->user['systemrole'] == User::SYSTEMROLE_SUPPORT ||
268✔
2772
            $this->user['systemrole'] == User::SYSTEMROLE_MODERATOR);
268✔
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);
207✔
3420

3421
        if ($me && $me->isModerator()) {
207✔
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
        if ($ctx && Utils::pres('reviewed', $ctx)) {
1✔
3470
            $ctxq = "users_comments.reviewed < " . $this->dbhr->quote($ctx['reviewed']) . " AND ";
1✔
3471
        }
3472

3473
        $groupq = $groupid ? " groupid = $groupid AND " : '';
1✔
3474

3475
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
1✔
3476
        $groupids = $me ? $me->getModeratorships() : [];
1✔
3477

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

3483
            $uids = array_unique(array_merge(array_column($comments, 'byuserid'), array_column($comments, 'userid')));
1✔
3484
            $u = new User($this->dbhr, $this->dbhm);
1✔
3485
            $users = $u->getPublicsById($uids, NULL, FALSE, FALSE, FALSE, FALSE);
1✔
3486

3487
            foreach ($comments as &$comment) {
1✔
3488
                $comment['date'] = Utils::ISODate($comment['date']);
1✔
3489
                $comment['reviewed'] = Utils::ISODate($comment['reviewed']);
1✔
3490

3491
                if (Utils::pres('userid', $comment)) {
1✔
3492
                    $comment['user'] = $users[$comment['userid']];
1✔
3493
                    unset($comment['userid']);
1✔
3494
                }
3495

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

3501
                $ctx['reviewed'] = $comment['reviewed'];
1✔
3502
            }
3503
        }
3504

3505
        return $comments;
1✔
3506
    }
3507

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

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

3518
        foreach ($comments as &$comment) {
2✔
3519
            $comment['date'] = Utils::ISODate($comment['date']);
2✔
3520
            $comment['reviewed'] = Utils::ISODate($comment['reviewed']);
2✔
3521

3522
            if (Utils::pres('byuserid', $comment)) {
2✔
3523
                $u = User::get($this->dbhr, $this->dbhm, $comment['byuserid']);
2✔
3524
                $comment['byuser'] = $u->getPublic();
2✔
3525
            }
3526

3527
            return ($comment);
2✔
3528
        }
3529

3530
        return (NULL);
1✔
3531
    }
3532

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

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

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

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

3558
                $rc = $this->dbhm->lastInsertId();
6✔
3559

3560
                $added = TRUE;
6✔
3561
            }
3562
        }
3563

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

3574
            $rc = $this->dbhm->lastInsertId();
1✔
3575
        }
3576

3577
        if ($rc && $flag) {
7✔
3578
            $this->flagOthers($groupid);
1✔
3579
        }
3580

3581
        return ($rc);
7✔
3582
    }
3583

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

3591
        foreach ($membs as $memb) {
2✔
3592
            $this->memberReview($memb['groupid'], TRUE, 'Note flagged to other groups');
2✔
3593
        }
3594
    }
3595

3596
    public function editComment($id, $user1 = NULL, $user2 = NULL, $user3 = NULL, $user4 = NULL, $user5 = NULL,
3597
                                $user6 = NULL, $user7 = NULL, $user8 = NULL, $user9 = NULL, $user10 = NULL,
3598
                                $user11 = NULL, $flag = FALSE)
3599
    {
3600
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
3✔
3601

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

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

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

3622
                if ($rc && $flag) {
3✔
3623
                    $this->flagOthers($comment['groupid']);
1✔
3624
                }
3625
            }
3626
        }
3627

3628
        return ($rc);
3✔
3629
    }
3630

3631
    public function deleteComment($id)
3632
    {
3633
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
2✔
3634

3635
        # Can only delete comments for a group on which we're a mod.
3636
        $rc = FALSE;
2✔
3637

3638
        $comments = $this->dbhr->preQuery("SELECT id, groupid FROM users_comments WHERE id = ?;", [
2✔
3639
            $id
2✔
3640
        ]);
2✔
3641

3642
        foreach ($comments as $comment) {
2✔
3643
            if ($me && ($me->isAdminOrSupport() || $me->isModOrOwner($comment['groupid']))) {
2✔
3644
                $rc = $this->dbhm->preExec("DELETE FROM users_comments WHERE id = ?;", [$id]);
2✔
3645
            }
3646
        }
3647

3648
        return ($rc);
2✔
3649
    }
3650

3651
    public function deleteComments()
3652
    {
3653
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
1✔
3654

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

3662
        return ($rc);
1✔
3663
    }
3664

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

3674
        $l->log([
2✔
3675
            'type' => Log::TYPE_USER,
2✔
3676
            'subtype' => Log::SUBTYPE_SPLIT,
2✔
3677
            'user' => $this->id,
2✔
3678
            'byuser' => $me ? $me->getId() : NULL,
2✔
3679
            'text' => "Split out $email"
2✔
3680
        ]);
2✔
3681

3682
        $u = new User($this->dbhr, $this->dbhm);
2✔
3683
        $uid2 = $u->create(NULL, NULL, $name);
2✔
3684
        $u->addEmail($email);
2✔
3685

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

3696
        # Chats which reference the messages sent from that email must also be intended for the split user.
3697
        $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✔
3698
            $email
2✔
3699
        ]);
2✔
3700

3701
        foreach ($chats as $chat) {
2✔
3702
            if ($chat['user1'] == $this->id) {
1✔
3703
                $this->dbhm->preExec("UPDATE chat_rooms SET user1 = ? WHERE id = ?;", [
1✔
3704
                    $uid2,
1✔
3705
                    $chat['id']
1✔
3706
                ]);
1✔
3707
            }
3708

3709
            if ($chat['user2'] == $this->id) {
1✔
3710
                $this->dbhm->preExec("UPDATE chat_rooms SET user2 = ? WHERE id = ?;", [
1✔
3711
                    $uid2,
1✔
3712
                    $chat['id']
1✔
3713
                ]);
1✔
3714
            }
3715
        }
3716

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

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

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

3731
        return ($uid2);
2✔
3732
    }
3733

3734
    public function FBL()
3735
    {
3736
        $settings = $this->loginLink(USER_SITE, $this->id, '/settings', User::SRC_FBL, TRUE);
1✔
3737
        $unsubscribe = $this->loginLink(USER_SITE, $this->id, '/settings', User::SRC_FBL, TRUE);
1✔
3738

3739
        $loader = new \Twig_Loader_Filesystem(IZNIK_BASE . '/mailtemplates/twig');
1✔
3740
        $twig = new \Twig_Environment($loader);
1✔
3741

3742
        $html = $twig->render('fbl.html', [
1✔
3743
            'email' => $this->getEmailPreferred(),
1✔
3744
            'unsubscribe' => $unsubscribe,
1✔
3745
            'settings' => $settings
1✔
3746
        ]);
1✔
3747

3748
        $message = \Swift_Message::newInstance()
1✔
3749
            ->setSubject("We've turned off emails for you")
1✔
3750
            ->setFrom([NOREPLY_ADDR => SITE_NAME])
1✔
3751
            ->setTo($this->getEmailPreferred())
1✔
3752
//            ->setBcc('log@ehibbert.org.uk')
1✔
3753
            ->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✔
3754

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

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

3766
        list ($transport, $mailer) = Mail::getMailer();
1✔
3767
        $this->sendIt($mailer, $message);
1✔
3768
    }
3769

3770
    public function forgotPassword($email)
3771
    {
3772
        $link = $this->loginLink(USER_SITE, $this->id, '/settings', User::SRC_FORGOT_PASSWORD, TRUE);
1✔
3773

3774
        $loader = new \Twig_Loader_Filesystem(IZNIK_BASE . '/mailtemplates/twig/welcome');
1✔
3775
        $twig = new \Twig_Environment($loader);
1✔
3776

3777
        $html = $twig->render('forgotpassword.html', [
1✔
3778
            'email' => $this->getEmailPreferred(),
1✔
3779
            'url' => $link,
1✔
3780
        ]);
1✔
3781

3782
        $message = \Swift_Message::newInstance()
1✔
3783
            ->setSubject("Forgot your password?")
1✔
3784
            ->setFrom([NOREPLY_ADDR => SITE_NAME])
1✔
3785
            ->setTo($email)
1✔
3786
            ->setBody("To set a new password, just log in here: $link");
1✔
3787

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

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

3799
        list ($transport, $mailer) = Mail::getMailer();
1✔
3800
        $this->sendIt($mailer, $message);
1✔
3801
    }
3802

3803
    public function verifyEmail($email, $force = FALSE)
3804
    {
3805
        # If this is one of our current emails, then we can just make it the primary.
3806
        $emails = $this->getEmails();
6✔
3807
        $handled = FALSE;
6✔
3808

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

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

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

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

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

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

3846
            list ($transport, $mailer) = Mail::getMailer();
5✔
3847
            $loader = new \Twig_Loader_Filesystem(IZNIK_BASE . '/mailtemplates/twig');
5✔
3848
            $twig = new \Twig_Environment($loader);
5✔
3849

3850
            $html = $twig->render('verifymail.html', [
5✔
3851
                'email' => $email,
5✔
3852
                'confirm' => $confirm
5✔
3853
            ]);
5✔
3854

3855
            $message = \Swift_Message::newInstance()
5✔
3856
                ->setSubject("Please verify your email")
5✔
3857
                ->setFrom([NOREPLY_ADDR => SITE_NAME])
5✔
3858
                ->setReturnPath($this->getBounce())
5✔
3859
                ->setTo([$email => $this->getName()])
5✔
3860
                ->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✔
3861

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

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

3873
            $this->sendIt($mailer, $message);
5✔
3874
        }
3875

3876
        return ($handled);
6✔
3877
    }
3878

3879
    public function confirmEmail($key)
3880
    {
3881
        $rc = FALSE;
2✔
3882
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
2✔
3883

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

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

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

3897
            $rc = $this->id;
2✔
3898
        }
3899

3900
        return ($rc);
2✔
3901
    }
3902

3903
    public function confirmUnsubscribe()
3904
    {
3905
        list ($transport, $mailer) = Mail::getMailer();
2✔
3906

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

3909
        $message = \Swift_Message::newInstance()
2✔
3910
            ->setSubject("Please confirm you want to leave Freegle")
2✔
3911
            ->setFrom(NOREPLY_ADDR)
2✔
3912
            ->setReplyTo(SUPPORT_ADDR)
2✔
3913
            ->setTo($this->getEmailPreferred())
2✔
3914
            ->setDate(time())
2✔
3915
            ->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✔
3916

3917
        Mail::addHeaders($this->dbhr, $this->dbhm, $message, Mail::UNSUBSCRIBE);
2✔
3918
        $this->sendIt($mailer, $message);
2✔
3919
    }
3920

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

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

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

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

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

3961
                        if (stripos($email, '%') !== FALSE) {
14✔
3962
                            # This may indicate a case where the real email is encoded on the LHS, eg gtempaccount.com
3963
                            $email = NULL;
1✔
3964
                        }
3965

3966
                        if (stripos($email, SITE_NAME) !== FALSE || stripos($email, 'freegle') !== FALSE) {
14✔
3967
                            $email = NULL;
1✔
3968
                        }
3969
                    }
3970
                }
3971

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

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

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

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

4014
                    do {
4015
                        $length = \Wordle\array_weighted_rand($lengths);
32✔
4016
                        $start = \Wordle\array_weighted_rand($bigrams);
32✔
4017
                        $email = strtolower(\Wordle\fill_word($start, $length, $trigrams)) . '-' . $this->id . '@' . USER_DOMAIN;
32✔
4018

4019
                        # Check that invented email doesn't contain any excluded words
4020
                        $q = strpos($email, '-');
32✔
4021
                        $wordPart = substr($email, 0, $q);
32✔
4022

4023
                        foreach ($excludeWords as $word) {
32✔
4024
                            if (stripos($wordPart, $word) !== FALSE) {
23✔
4025
                                $email = NULL;
×
4026
                                break;
×
4027
                            }
4028
                        }
4029
                    } while (!$email);
32✔
4030
                }
4031
            }
4032
        }
4033

4034
        return ($email);
45✔
4035
    }
4036

4037
    public function delete($groupid = NULL, $subject = NULL, $body = NULL, $log = TRUE)
4038
    {
4039
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
18✔
4040

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

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

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

4060
        return ($rc);
18✔
4061
    }
4062

4063
    public function getUnsubLink($domain, $id, $type = NULL, $auto = FALSE)
4064
    {
4065
        return (User::loginLink($domain, $id, "/unsubscribe/$id", $type, $auto));
26✔
4066
    }
4067

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

4083
    public function loginLink($domain, $id, $url = '/', $type = NULL, $auto = FALSE)
4084
    {
4085
        $p = strpos($url, '?');
58✔
4086
        $ret = $p === FALSE ? "https://$domain$url?u=$id&src=$type" : "https://$domain$url&u=$id&src=$type";
58✔
4087

4088
        if ($auto) {
58✔
4089
            # Get a per-user link we can use to log in without a password.
4090
            $key = $this->getUserKey($id);
11✔
4091

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

4095
            $p = strpos($url, '?');
11✔
4096
            $src = $type ? "&src=$type" : "";
11✔
4097
            $ret = $p === FALSE ? ("https://$domain$url?u=$id&k=$key$src") : ("https://$domain$url&u=$id&k=$key$src");
11✔
4098
        }
4099

4100
        return ($ret);
58✔
4101
    }
4102

4103
    public function sendOurMails($g = NULL, $checkholiday = TRUE, $checkbouncing = TRUE)
4104
    {
4105
        if ($this->getPrivate('deleted')) {
102✔
4106
            return FALSE;
1✔
4107
        }
4108

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

4116
        # We don't want to send emails to people who haven't been active for more than six months.  This improves
4117
        # our spam reputation, by avoiding honeytraps.
4118
        $sendit = FALSE;
102✔
4119
        $lastaccess = strtotime($this->getPrivate('lastaccess'));
102✔
4120

4121
        // This time is also present on the client in ModMember, and in Engage.
4122
        if (time() - $lastaccess <= Engage::USER_INACTIVE) {
102✔
4123
            $sendit = TRUE;
102✔
4124

4125
            if ($sendit && $checkholiday) {
102✔
4126
                # We might be on holiday.
4127
                $hol = $this->getPrivate('onholidaytill');
23✔
4128
                $till = $hol ? strtotime($hol) : 0;
23✔
4129
                #error_log("Holiday $till vs " . time());
4130

4131
                $sendit = time() > $till;
23✔
4132
            }
4133

4134
            if ($sendit && $checkbouncing) {
102✔
4135
                # And don't send if we're bouncing.
4136
                $sendit = !$this->getPrivate('bouncing');
23✔
4137
                #error_log("After bouncing $sendit");
4138
            }
4139
        }
4140

4141
        #error_log("Sendit? $sendit");
4142
        return ($sendit);
102✔
4143
    }
4144

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

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

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

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

4185
        return ($ret);
5✔
4186
    }
4187

4188
    public function search($search, $ctx)
4189
    {
4190
        if (preg_replace('/\-|\~/', '', $search) ==  '') {
5✔
4191
            # Most likely an encoded id.
4192
            $search = User::decodeId($search);
×
4193
        }
4194

4195
        if (preg_match('/story-(.*)/', $search, $matches)) {
5✔
4196
            # Story.
4197
            $s = new Story($this->dbhr, $this->dbhm, $matches[1]);
×
4198
            $search = $s->getPrivate('userid');
×
4199
        }
4200

4201
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
5✔
4202
        $id = intval(Utils::presdef('id', $ctx, 0));
5✔
4203
        $ctx = $ctx ? $ctx : [];
5✔
4204
        $q = $this->dbhr->quote("$search%");
5✔
4205
        $backwards = strrev($search);
5✔
4206
        $qb = $this->dbhr->quote("$backwards%");
5✔
4207

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

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

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

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

4226
        $ret = [];
5✔
4227

4228
        foreach ($users as $user) {
5✔
4229
            $ctx['id'] = $user['userid'];
4✔
4230

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

4233
            $thisone = $u->getPublic(NULL, TRUE, TRUE, TRUE, TRUE, FALSE, TRUE, [
4✔
4234
                MessageCollection::PENDING,
4✔
4235
                MessageCollection::APPROVED
4✔
4236
            ], TRUE);
4✔
4237

4238
            # We might not have the emails.
4239
            $thisone['email'] = $u->getEmailPreferred();
4✔
4240
            $thisone['emails'] = $u->getEmails();
4✔
4241

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

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

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

4251
            # Also return the chats for this user.  Can't use ChatRooms::listForUser because that would exclude any
4252
            # chats on groups where we were no longer a member.
4253
            $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✔
4254
                $user['userid'],
4✔
4255
                ChatRoom::TYPE_USER2USER,
4✔
4256
                $user['userid'],
4✔
4257
            ]), 'id'));
4✔
4258

4259
            $thisone['chatrooms'] = [];
4✔
4260

4261
            if ($rooms) {
4✔
4262
                $r = new ChatRoom($this->dbhr, $this->dbhm);
×
4263
                $thisone['chatrooms'] = $r->fetchRooms($rooms, $user['userid'], FALSE);
×
4264
            }
4265

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

4275
            $thisone['comments'] = $this->getCommentsForSingleUser($user['userid']);
4✔
4276
            $thisone['tnuserid'] = $u->getPrivate('tnuserid');
4✔
4277

4278
            $push = $this->dbhr->preQuery("SELECT MAX(lastsent) AS lastpush FROM users_push_notifications WHERE userid = ?;", [
4✔
4279
                $user['userid']
4✔
4280
            ]);
4✔
4281

4282
            foreach ($push as $p) {
4✔
4283
                $thisone['lastpush'] = Utils::ISODate($p['lastpush']);
4✔
4284
            }
4285

4286
            $thisone['info'] = $u->getInfo();
4✔
4287
            $thisone['trustlevel'] = $u->getPrivate('trustlevel');
4✔
4288

4289
            $bans = $this->dbhr->preQuery("SELECT * FROM users_banned WHERE userid = ?;", [
4✔
4290
                $u->getId()
4✔
4291
            ]);
4✔
4292

4293
            $thisone['bans'] = [];
4✔
4294

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

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

4309
            if ($me->hasPermission(User::PERM_GIFTAID)) {
4✔
4310
                $thisone['donations'] = $d->listByUser($user['userid']);
2✔
4311
            }
4312

4313
            $thisone['newsfeedmodstatus'] = $u->getPrivate('newsfeedmodstatus');
4✔
4314
            $thisone['newsfeed'] = $this->dbhr->preQuery("SELECT id, message, timestamp, hidden, hiddenby, deleted, deletedby FROM newsfeed WHERE userid = ? ORDER BY id DESC;", [
4✔
4315
                $user['userid']
4✔
4316
            ]);
4✔
4317

4318
            foreach ($thisone['newsfeed'] as &$nf) {
4✔
4319
                $nf['timestamp'] = Utils::ISODate($nf['timestamp']);
×
4320
                $nf['deleted'] = Utils::ISODate($nf['deleted']);
×
4321
                $nf['hidden'] = Utils::ISODate($nf['hidden']);
×
4322
            }
4323

4324
            $ret[] = $thisone;
4✔
4325
        }
4326

4327
        return ($ret);
5✔
4328
    }
4329

4330
    private function safeGetPostcode($val) {
4331
        $ret = [ NULL, NULL ];
56✔
4332

4333
        $settings = $val ? json_decode($val, TRUE) : [];
56✔
4334

4335
        if (Utils::pres('mylocation', $settings) &&
56✔
4336
            Utils::presdef('type', $settings['mylocation'], NULL) == 'Postcode') {
56✔
4337
            $ret = [
13✔
4338
                Utils::presdef('id', $settings['mylocation'], NULL),
13✔
4339
                Utils::presdef('name', $settings['mylocation'], NULL)
13✔
4340
            ];
13✔
4341
        }
4342

4343
        return $ret;
56✔
4344
    }
4345

4346
    public function setPrivate($att, $val)
4347
    {
4348
        if (!strcmp($att, 'settings') && $val) {
193✔
4349
            # Possible location change.
4350
            list ($oldid, $oldloc) = $this->safeGetPostcode($this->getPrivate('settings'));
56✔
4351
            list ($newid, $newloc) = $this->safeGetPostcode($val);
56✔
4352

4353
            if ($oldloc !== $newloc) {
56✔
4354
                # We have changed our location.
4355
                parent::setPrivate('lastlocation', $newid);
13✔
4356
                $i = new Isochrone($this->dbhr, $this->dbhm);
13✔
4357
                $i->deleteForUser($this->id);
13✔
4358

4359
                $this->log->log([
13✔
4360
                            'type' => Log::TYPE_USER,
13✔
4361
                            'subtype' => Log::SUBTYPE_POSTCODECHANGE,
13✔
4362
                            'user' => $this->id,
13✔
4363
                            'text' => $newloc
13✔
4364
                        ]);
13✔
4365
            }
4366

4367
            // Prune the info in the settings to remove any groupsnear info, which would use space and is not needed.
4368
            $val = User::pruneSettings($val);
56✔
4369
        }
4370

4371
        User::clearCache($this->id);
193✔
4372
        parent::setPrivate($att, $val);
193✔
4373
    }
4374

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

4385
        return $val;
56✔
4386
    }
4387

4388
    public function canMerge()
4389
    {
4390
        $settings = Utils::pres('settings', $this->user) ? json_decode($this->user['settings'], TRUE) : [];
16✔
4391
        return (array_key_exists('canmerge', $settings) ? $settings['canmerge'] : TRUE);
16✔
4392
    }
4393

4394
    public function notifsOn($type, $groupid = NULL)
4395
    {
4396
        if ($this->getPrivate('deleted')) {
85✔
4397
            return FALSE;
1✔
4398
        }
4399

4400
        $settings = Utils::pres('settings', $this->user) ? json_decode($this->user['settings'], TRUE) : [];
85✔
4401
        $notifs = Utils::pres('notifications', $settings);
85✔
4402

4403
        $defs = [
85✔
4404
            self::NOTIFS_EMAIL => TRUE,
85✔
4405
            self::NOTIFS_EMAIL_MINE => FALSE,
85✔
4406
            self::NOTIFS_PUSH => TRUE
85✔
4407
        ];
85✔
4408

4409
        $ret = ($notifs && array_key_exists($type, $notifs)) ? $notifs[$type] : $defs[$type];
85✔
4410

4411
        if ($ret && $groupid) {
85✔
4412
            # Check we're an active mod on this group - if not then we don't want the notifications.
4413
            $ret = $this->activeModForGroup($groupid);
5✔
4414
        }
4415

4416
        #error_log("Notifs on for user #{$this->id} type $type ? $ret from " . var_export($notifs, TRUE));
4417
        return ($ret);
85✔
4418
    }
4419

4420
    public function getNotificationPayload($modtools)
4421
    {
4422
        # This gets a notification count/title/message for this user.
4423
        $notifcount = 0;
13✔
4424
        $title = '';
13✔
4425
        $message = NULL;
13✔
4426
        $chatids = [];
13✔
4427
        $route = NULL;
13✔
4428
        $category = NULL;
13✔
4429
        $threadId = NULL;
13✔
4430
        $image = NULL;
13✔
4431

4432
        if (!$modtools) {
13✔
4433
            # User notification.  We want to show a count of chat messages, or some of the message if there is just one.
4434
            $r = new ChatRoom($this->dbhr, $this->dbhm);
8✔
4435
            $unseen = $r->allUnseenForUser($this->id, [ChatRoom::TYPE_USER2USER, ChatRoom::TYPE_USER2MOD], $modtools);
8✔
4436
            $chatcount = count($unseen);
8✔
4437
            $total = $chatcount;
8✔
4438
            foreach ($unseen as $un) {
8✔
4439
                $chatids[] = $un['chatid'];
4✔
4440
            };
4441

4442
            #error_log("Chats with unseen " . var_export($chatids, TRUE));
4443
            $n = new Notifications($this->dbhr, $this->dbhm);
8✔
4444
            $notifcount = $n->countUnseen($this->id);
8✔
4445

4446
            if ($total ==  1) {
8✔
4447
                $r = new ChatRoom($this->dbhr, $this->dbhm, $unseen[0]['chatid']);
3✔
4448
                $atts = $r->getPublic($this);
3✔
4449
                $title = $atts['name'];
3✔
4450
                list($msgs, $users) = $r->getMessages(100, 0);
3✔
4451

4452
                if (count($msgs) > 0) {
3✔
4453
                    $message = Utils::presdef('message', $msgs[count($msgs) - 1], "You have a message");
3✔
4454

4455
                    # Decode emoji escape sequences to actual emojis for display.
4456
                    $message = Utils::decodeEmojis($message);
3✔
4457

4458
                    $message = strlen($message) > 256 ? (substr($message, 0, 256) . "...") : $message;
3✔
4459
                }
4460

4461
                $route = "/chats/" . $unseen[0]['chatid'];
3✔
4462
                $category = PushNotifications::CATEGORY_CHAT_MESSAGE;
3✔
4463
                $threadId = 'chat_' . $unseen[0]['chatid'];
3✔
4464
                $image = Utils::presdef('icon', $atts, NULL);
3✔
4465

4466
                if ($notifcount) {
3✔
4467
                    $total += $notifcount;
3✔
4468
                }
4469
            } else if ($total > 1) {
5✔
4470
                $title = "You have $total new messages";
1✔
4471
                $route = "/chats";
1✔
4472
                $category = PushNotifications::CATEGORY_CHAT_MESSAGE;
1✔
4473
                $threadId = 'chats';
1✔
4474

4475
                if ($notifcount) {
1✔
4476
                    $total += $notifcount;
1✔
4477
                    $title .= " and $notifcount notification" . ($notifcount == 1 ? '' : 's');
1✔
4478
                }
4479
            } else {
4480
                # Add in the notifications you see primarily from the newsfeed.
4481
                if ($notifcount) {
5✔
4482
                    $total += $notifcount;
5✔
4483
                    $ctx = NULL;
5✔
4484
                    $notifs = $n->get($this->id, $ctx);
5✔
4485
                    $title = $n->getNotifTitle($notifs);
5✔
4486

4487
                    if ($title) {
5✔
4488
                        $route = '/';
5✔
4489

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

4529
            $work = $this->getWorkCounts();
7✔
4530
            $total = $work['total'] + $chatcount;
7✔
4531

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

4547
            $title = $chatcount ? ("$chatcount chat message" . ($chatcount != 1 ? 's' : '') . "\n") : '';
7✔
4548
            $route = NULL;
7✔
4549

4550
            foreach ($types as $type => $vals) {
7✔
4551
                if (Utils::presdef($type, $work, 0) > 0) {
7✔
4552
                    $title .= $work[$type] . ' ' . ($work[$type] != 1 ? $vals[1] : $vals[0] ) . "\n";
3✔
4553
                    $route = $vals[2];
3✔
4554
                }
4555
            }
4556

4557
            $title = $title == '' ? NULL : $title;
7✔
4558
        }
4559

4560

4561
        return ([$total, $chatcount, $notifcount, $title, $message, array_unique($chatids), $route, $category, $threadId, $image]);
13✔
4562
    }
4563

4564
    public function hasPermission($perm)
4565
    {
4566
        $perms = $this->user['permissions'];
41✔
4567
        return ($perms && stripos($perms, $perm) !== FALSE);
41✔
4568
    }
4569

4570
    public function sendIt($mailer, $message)
4571
    {
4572
        $mailer->send($message);
28✔
4573
    }
4574

4575
    public function thankDonation()
4576
    {
4577
        try {
4578
            $loader = new \Twig_Loader_Filesystem(IZNIK_BASE . '/mailtemplates/twig/donations');
1✔
4579
            $twig = new \Twig_Environment($loader);
1✔
4580
            list ($transport, $mailer) = Mail::getMailer();
1✔
4581

4582
            $message = \Swift_Message::newInstance()
1✔
4583
                ->setSubject("Thank you for supporting Freegle!")
1✔
4584
                ->setFrom(PAYPAL_THANKS_FROM)
1✔
4585
                ->setReplyTo(PAYPAL_THANKS_FROM)
1✔
4586
                ->setTo($this->getEmailPreferred())
1✔
4587
                ->setBody("Thank you for supporting Freegle!");
1✔
4588

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

4591
            $html = $twig->render('thank.html', [
1✔
4592
                'name' => $this->getName(),
1✔
4593
                'email' => $this->getEmailPreferred(),
1✔
4594
                'unsubscribe' => $this->loginLink(USER_SITE, $this->getId(), "/unsubscribe", NULL)
1✔
4595
            ]);
1✔
4596

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

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

4608
            $this->sendIt($mailer, $message);
1✔
4609
        } catch (\Exception $e) { error_log("Failed " . $e->getMessage()); };
×
4610
    }
4611

4612
    public function invite($email)
4613
    {
4614
        $ret = FALSE;
9✔
4615

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

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

4636
                        # We're ok to invite.
4637
                        $fromname = $this->getName();
9✔
4638
                        $frommail = $this->getEmailPreferred();
9✔
4639
                        $url = "https://" . USER_SITE . "/invite/" . $this->dbhm->lastInsertId();
9✔
4640

4641
                        list ($transport, $mailer) = Mail::getMailer();
9✔
4642
                        $message = \Swift_Message::newInstance()
9✔
4643
                            ->setSubject("$fromname has invited you to try Freegle!")
9✔
4644
                            ->setFrom([NOREPLY_ADDR => SITE_NAME])
9✔
4645
                            ->setReplyTo($frommail)
9✔
4646
                            ->setTo($email)
9✔
4647
                            ->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✔
4648

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

4651
                        $html = invite($fromname, $frommail, $url);
9✔
4652

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

4662
                        $this->sendIt($mailer, $message);
9✔
4663
                        $ret = TRUE;
9✔
4664

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

4675
        return ($ret);
9✔
4676
    }
4677

4678
    public function inviteOutcome($id, $outcome)
4679
    {
4680
        $invites = $this->dbhm->preQuery("SELECT * FROM users_invitations WHERE id = ?;", [
1✔
4681
            $id
1✔
4682
        ]);
1✔
4683

4684
        foreach ($invites as $invite) {
1✔
4685
            if ($invite['outcome'] == User::INVITE_PENDING) {
1✔
4686
                $this->dbhm->preExec("UPDATE users_invitations SET outcome = ?, outcometimestamp = NOW() WHERE id = ?;", [
1✔
4687
                    $outcome,
1✔
4688
                    $id
1✔
4689
                ]);
1✔
4690

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

4703
    public function listInvitations($since = "30 days ago")
4704
    {
4705
        $ret = [];
8✔
4706

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

4713
        foreach ($invites as $invite) {
8✔
4714
            # Check if this email is now on the platform.
4715
            $invite['date'] = Utils::ISODate($invite['date']);
7✔
4716
            $invite['outcometimestamp'] = $invite['outcometimestamp'] ? Utils::ISODate($invite['outcometimestamp']) : NULL;
7✔
4717
            $ret[] = $invite;
7✔
4718
        }
4719

4720
        return ($ret);
8✔
4721
    }
4722

4723
    public function getLatLng($usedef = TRUE, $usegroup = TRUE, $blur = Utils::BLUR_NONE)
4724
    {
4725
        $ret = [ 0, 0, NULL ];
170✔
4726

4727
        if ($this->id) {
170✔
4728
            $locs = $this->getLatLngs([ $this->user ], $usedef, $usegroup, FALSE, [ $this->user ]);
170✔
4729
            $loc = $locs[$this->id];
170✔
4730

4731
            if ($loc) {
170✔
4732
                if ($blur && ($loc['lat'] || $loc['lng'])) {
168✔
4733
                    list ($loc['lat'], $loc['lng']) = Utils::blur($loc['lat'], $loc['lng'], $blur);
4✔
4734
                }
4735

4736
                $ret = [ $loc['lat'], $loc['lng'], Utils::presdef('loc', $loc, NULL) ];
168✔
4737
            }
4738
        }
4739

4740
        return $ret;
170✔
4741
    }
4742

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

4756
        if ($idsleft && count($idsleft)) {
107✔
4757
            # First try to get the location from settings or last location.
4758
            $atts = $atts ? $atts : $this->dbhr->preQuery("SELECT id, settings, lastlocation FROM users WHERE id in (" . implode(',', $idsleft) . ");", NULL, FALSE, FALSE);
107✔
4759

4760
            foreach ($atts as $att) {
107✔
4761
                $loc = NULL;
107✔
4762
                $grp = NULL;
107✔
4763

4764
                $aid = NULL;
107✔
4765
                $lid = NULL;
107✔
4766
                $lat = NULL;
107✔
4767
                $lng = NULL;
107✔
4768

4769
                # Default to nowhere.
4770
                $users[$att['id']]['info']['publiclocation'] = [
107✔
4771
                    'display' => '',
107✔
4772
                    'location' => NULL,
107✔
4773
                    'groupname' => NULL
107✔
4774
                ];
107✔
4775

4776
                if (Utils::pres('settings', $att)) {
107✔
4777
                    $settings = $att['settings'];
22✔
4778
                    $settings = json_decode($settings, TRUE);
22✔
4779

4780
                    if (Utils::pres('mylocation', $settings) && Utils::pres('area', $settings['mylocation'])) {
22✔
4781
                        $loc = $settings['mylocation']['area']['name'];
7✔
4782
                        $lid = $settings['mylocation']['id'];
7✔
4783
                        $lat = $settings['mylocation']['lat'];
7✔
4784
                        $lng = $settings['mylocation']['lng'];
7✔
4785
                    }
4786
                }
4787

4788
                if (!$loc) {
107✔
4789
                    # Get the name of the last area we used.
4790
                    if (is_null($areas)) {
100✔
4791
                        $areas = $this->dbhr->preQuery("SELECT l2.id, l2.name, l2.lat, l2.lng, users.id AS userid FROM locations l1 
100✔
4792
                            INNER JOIN users ON users.lastlocation = l1.id
4793
                            INNER JOIN locations l2 ON l2.id = l1.areaid
4794
                            WHERE users.id IN (" . implode(',', $idsleft) . ");", NULL, FALSE, FALSE);
100✔
4795
                    }
4796

4797
                    foreach ($areas as $area) {
100✔
4798
                        if ($att['id'] ==  $area['userid']) {
24✔
4799
                            $loc = $area['name'];
24✔
4800
                            $lid = $area['id'];
24✔
4801
                            $lat = $area['lat'];
24✔
4802
                            $lng = $area['lng'];
24✔
4803
                        }
4804
                    }
4805
                }
4806

4807
                if (!$lid) {
107✔
4808
                    # Find the group of which we are a member which is closest to our location.  We do this because generally
4809
                    # the number of groups we're in is small and therefore this will be quick, whereas the groupsNear call is
4810
                    # fairly slow.
4811
                    $closestdist = PHP_INT_MAX;
100✔
4812
                    $closestname = NULL;
100✔
4813

4814
                    # Get all the memberships.
4815
                    if (!$membs) {
100✔
4816
                        $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✔
4817
                                ',',
100✔
4818
                                $idsleft
100✔
4819
                            ) . ") ORDER BY added ASC;";
100✔
4820
                        $membs = $this->dbhr->preQuery($sql);
100✔
4821
                    }
4822

4823
                    foreach ($membs as $memb) {
100✔
4824
                        if ($memb['userid'] == $att['id']) {
89✔
4825
                            $dist = \GreatCircle::getDistance($lat, $lng, $memb['lat'], $memb['lng']);
89✔
4826

4827
                            if ($dist < $closestdist) {
89✔
4828
                                $closestdist = $dist;
89✔
4829
                                $closestname = $memb['namefull'] ? $memb['namefull'] : $memb['nameshort'];
89✔
4830
                            }
4831
                        }
4832
                    }
4833

4834
                    if (!is_null($closestname)) {
100✔
4835
                        $grp = $closestname;
89✔
4836

4837
                        # The location name might be in the group name, in which case just use the group.
4838
                        $loc = stripos($grp, $loc) !== FALSE ? NULL : $loc;
89✔
4839
                    }
4840
                }
4841

4842
                if ($loc) {
107✔
4843
                    $display = $loc ? ($loc . ($grp ? ", $grp" : "")) : ($grp ? $grp : '');
31✔
4844

4845
                    $users[$att['id']]['info']['publiclocation'] = [
31✔
4846
                        'display' => $display,
31✔
4847
                        'location' => $loc,
31✔
4848
                        'groupname' => $grp
31✔
4849
                    ];
31✔
4850

4851
                    $idsleft = array_filter($idsleft, function($val) use ($att) {
31✔
4852
                        return($val != $att['id']);
31✔
4853
                    });
31✔
4854
                }
4855
            }
4856

4857
            if (count($idsleft) > 0) {
107✔
4858
                # We have some left which don't have explicit postcodes.  Try for a group name.
4859
                #
4860
                # First check the group we used most recently.
4861
                #error_log("Look for group name only for {$att['id']}");
4862
                $found = [];
100✔
4863
                foreach ($idsleft as $userid) {
100✔
4864
                    $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✔
4865
                        $userid
100✔
4866
                    ]);
100✔
4867

4868
                    foreach ($messages as $msg) {
100✔
4869
                        list ($type, $item, $location) = Message::parseSubject($msg['subject']);
63✔
4870

4871
                        if ($item) {
63✔
4872
                            $grp = $location;
44✔
4873

4874
                            // Handle some misformed locations which end up with spurious brackets.
4875
                            $grp = preg_replace('/\(|\)/', '', $grp);
44✔
4876

4877
                            $users[$userid]['info']['publiclocation'] = [
44✔
4878
                                'display' => $grp,
44✔
4879
                                'location' => NULL,
44✔
4880
                                'groupname' => $grp
44✔
4881
                            ];
44✔
4882

4883
                            $found[] = $userid;
44✔
4884
                        }
4885
                    }
4886
                }
4887

4888
                $idsleft = array_diff($idsleft, $found);
100✔
4889
                
4890
                # Now check just membership.
4891
                if (count($idsleft)) {
100✔
4892
                    if (!$membs) {
66✔
4893
                        $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✔
4894
                                ',',
22✔
4895
                                $idsleft
22✔
4896
                            ) . ") ORDER BY added ASC;";
22✔
4897
                        $membs = $this->dbhr->preQuery($sql);
22✔
4898
                    }
4899
                    
4900
                    foreach ($idsleft as $userid) {
66✔
4901
                        # Now check the group we joined most recently.
4902
                        foreach ($membs as $memb) {
66✔
4903
                            if ($memb['userid'] == $userid) {
48✔
4904
                                $grp = $memb['namefull'] ? $memb['namefull'] : $memb['nameshort'];
48✔
4905

4906
                                $users[$userid]['info']['publiclocation'] = [
48✔
4907
                                    'display' => $grp,
48✔
4908
                                    'location' => NULL,
48✔
4909
                                    'groupname' => $grp
48✔
4910
                                ];
48✔
4911
                            }
4912
                        }
4913
                    }
4914
                }
4915
            }
4916
        }
4917
    }
4918

4919
    public function getLatLngs($users, $usedef = TRUE, $usegroup = TRUE, $needgroup = FALSE, $atts = NULL, $blur = NULL)
4920
    {
4921
        $userids = array_filter(array_column($users, 'id'));
171✔
4922
        $ret = [];
171✔
4923

4924
        if ($userids && count($userids)) {
171✔
4925
            $atts = $atts ? $atts : $this->dbhr->preQuery("SELECT id, settings, lastlocation FROM users WHERE id in (" . implode(',', $userids) . ");", NULL, FALSE, FALSE);
171✔
4926

4927
            foreach ($atts as $att) {
171✔
4928
                $lat = NULL;
171✔
4929
                $lng = NULL;
171✔
4930
                $loc = NULL;
171✔
4931

4932
                if (Utils::pres('settings', $att)) {
171✔
4933
                    $settings = $att['settings'];
34✔
4934
                    $settings = json_decode($settings, TRUE);
34✔
4935

4936
                    if (Utils::pres('mylocation', $settings)) {
34✔
4937
                        $lat = $settings['mylocation']['lat'];
30✔
4938
                        $lng = $settings['mylocation']['lng'];
30✔
4939
                        $loc = Utils::presdef('name', $settings['mylocation'], NULL);
30✔
4940
                        #error_log("Got from mylocation $lat, $lng, $loc");
4941
                    }
4942
                }
4943

4944
                if (is_null($lat)) {
171✔
4945
                    $lid = $att['lastlocation'];
154✔
4946

4947
                    if ($lid) {
154✔
4948
                        $l = new Location($this->dbhr, $this->dbhm, $lid);
23✔
4949
                        $lat = $l->getPrivate('lat');
23✔
4950
                        $lng = $l->getPrivate('lng');
23✔
4951
                        $loc = $l->getPrivate('name');
23✔
4952
                        #error_log("Got from last location $lat, $lng, $loc");
4953
                    }
4954
                }
4955

4956
                if (!is_null($lat)) {
171✔
4957
                    $ret[$att['id']] = [
49✔
4958
                        'lat' => $lat,
49✔
4959
                        'lng' => $lng,
49✔
4960
                        'loc' => $loc,
49✔
4961
                    ];
49✔
4962

4963
                    $userids = array_filter($userids, function($id) use ($att) {
49✔
4964
                        return $id != $att['id'];
49✔
4965
                    });
49✔
4966
                }
4967
            }
4968
        }
4969

4970
        if ($userids && count($userids) && $usegroup) {
171✔
4971
            # Still some we haven't handled.  Get the last message posted on a group with a location, if any.
4972
            $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);
147✔
4973
            foreach ($membs as $memb) {
147✔
4974
                $ret[$memb['userid']] = [
3✔
4975
                    'lat' => $memb['lat'],
3✔
4976
                    'lng' => $memb['lng']
3✔
4977
                ];
3✔
4978

4979
                #error_log("Got from last message posted {$memb['lat']}, {$memb['lng']}");
4980

4981
                $userids = array_filter($userids, function($id) use ($memb) {
3✔
4982
                    return $id != $memb['userid'];
3✔
4983
                });
3✔
4984
            }
4985
        }
4986

4987
        if ($userids && count($userids) && $usegroup) {
171✔
4988
            # Still some we haven't handled.  Get the memberships.  Logic will choose most recently joined.
4989
            $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);
145✔
4990
            foreach ($membs as $memb) {
145✔
4991
                $ret[$memb['userid']] = [
127✔
4992
                    'lat' => $memb['lat'],
127✔
4993
                    'lng' => $memb['lng'],
127✔
4994
                    'group' => Utils::presdef('namefull', $memb, $memb['nameshort'])
127✔
4995
                ];
127✔
4996

4997
                #error_log("Got from membership {$memb['lat']}, {$memb['lng']}, " . Utils::presdef('namefull', $memb, $memb['nameshort']));
4998

4999
                $userids = array_filter($userids, function($id) use ($memb) {
127✔
5000
                    return $id != $memb['userid'];
127✔
5001
                });
127✔
5002
            }
5003
        }
5004

5005
        if ($userids && count($userids)) {
171✔
5006
            # Still some we haven't handled.
5007
            foreach ($userids as $userid) {
26✔
5008
                if ($usedef) {
26✔
5009
                    $ret[$userid] = [
21✔
5010
                        'lat' => 53.9450,
21✔
5011
                        'lng' => -2.5209
21✔
5012
                    ];
21✔
5013
                } else {
5014
                    $ret[$userid] = NULL;
15✔
5015
                }
5016
            }
5017
        }
5018

5019
        if ($needgroup) {
171✔
5020
            # Get a group name.
5021
            $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✔
5022
            foreach ($membs as $memb) {
7✔
5023
                $ret[$memb['userid']]['group'] = Utils::presdef('namefull', $memb, $memb['nameshort']);
7✔
5024
            }
5025
        }
5026

5027
        if ($blur) {
171✔
5028
            foreach ($ret as &$memb) {
7✔
5029
                if ($memb['lat'] || $memb['lng']) {
7✔
5030
                    list ($memb['lat'], $memb['lng']) = Utils::blur($memb['lat'], $memb['lng'], $blur);
7✔
5031
                }
5032
            }
5033
        }
5034

5035
        return ($ret);
171✔
5036
    }
5037

5038
    public function isFreegleMod()
5039
    {
5040
        $ret = FALSE;
174✔
5041

5042
        $this->cacheMemberships();
174✔
5043

5044
        foreach ($this->memberships as $mem) {
174✔
5045
            if ($mem['type'] == Group::GROUP_FREEGLE && ($mem['role'] == User::ROLE_OWNER || $mem['role'] == User::ROLE_MODERATOR)) {
143✔
5046
                $ret = TRUE;
43✔
5047
            }
5048
        }
5049

5050
        return ($ret);
174✔
5051
    }
5052

5053
    public function getKudos($id = NULL)
5054
    {
5055
        $id = $id ? $id : $this->id;
1✔
5056
        $kudos = [
1✔
5057
            'userid' => $id,
1✔
5058
            'posts' => 0,
1✔
5059
            'chats' => 0,
1✔
5060
            'newsfeed' => 0,
1✔
5061
            'events' => 0,
1✔
5062
            'vols' => 0,
1✔
5063
            'facebook' => 0,
1✔
5064
            'platform' => 0,
1✔
5065
            'kudos' => 0,
1✔
5066
        ];
1✔
5067

5068
        $kudi = $this->dbhr->preQuery("SELECT * FROM users_kudos WHERE userid = ?;", [
1✔
5069
            $id
1✔
5070
        ]);
1✔
5071

5072
        foreach ($kudi as $k) {
1✔
5073
            $kudos = $k;
1✔
5074
        }
5075

5076
        return ($kudos);
1✔
5077
    }
5078

5079
    public function updateKudos($id = NULL, $force = FALSE)
5080
    {
5081
        $current = $this->getKudos($id);
1✔
5082

5083
        # Only update if we don't have one or it's older than a day.  This avoids repeatedly updating the entry
5084
        # for the same user in some bulk operations.
5085
        if (!Utils::pres('timestamp', $current) || (time() - strtotime($current['timestamp']) > 24 * 60 * 60)) {
1✔
5086
            # We analyse a user's activity and assign them a level.
5087
            #
5088
            # Only interested in activity in the last year.
5089
            $id = $id ? $id : $this->id;
1✔
5090
            $start = date('Y-m-d', strtotime("365 days ago"));
1✔
5091

5092
            # First, the number of months in which they have posted.
5093
            $posts = $this->dbhr->preQuery("SELECT COUNT(DISTINCT(CONCAT(YEAR(date), '-', MONTH(date)))) AS count FROM messages WHERE fromuser = ? AND date >= '$start';", [
1✔
5094
                $id
1✔
5095
            ])[0]['count'];
1✔
5096

5097
            # Ditto communicated with people.
5098
            $chats = $this->dbhr->preQuery("SELECT COUNT(DISTINCT(CONCAT(YEAR(date), '-', MONTH(date)))) AS count FROM chat_messages WHERE userid = ? AND date >= '$start';", [
1✔
5099
                $id
1✔
5100
            ])[0]['count'];
1✔
5101

5102
            # Newsfeed posts
5103
            $newsfeed = $this->dbhr->preQuery("SELECT COUNT(DISTINCT(CONCAT(YEAR(timestamp), '-', MONTH(timestamp)))) AS count FROM newsfeed WHERE userid = ? AND added >= '$start';", [
1✔
5104
                $id
1✔
5105
            ])[0]['count'];
1✔
5106

5107
            # Events
5108
            $events = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM communityevents WHERE userid = ? AND added >= '$start';", [
1✔
5109
                $id
1✔
5110
            ])[0]['count'];
1✔
5111

5112
            # Volunteering
5113
            $vols = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM volunteering WHERE userid = ? AND added >= '$start';", [
1✔
5114
                $id
1✔
5115
            ])[0]['count'];
1✔
5116

5117
            # Do they have a Facebook login?
5118
            $facebook = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM users_logins WHERE userid = ? AND type = ?", [
1✔
5119
                    $id,
1✔
5120
                    User::LOGIN_FACEBOOK
1✔
5121
                ])[0]['count'] > 0;
1✔
5122

5123
            # Have they posted using the platform?
5124
            $platform = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM messages WHERE fromuser = ? AND arrival >= '$start' AND sourceheader = ?;", [
1✔
5125
                    $id,
1✔
5126
                    Message::PLATFORM
1✔
5127
                ])[0]['count'] > 0;
1✔
5128

5129
            $kudos = $posts + $chats + $newsfeed + $events + $vols;
1✔
5130

5131
            if ($kudos > 0 || $force) {
1✔
5132
                # No sense in creating entries which are blank or the same.
5133
                $current = $this->getKudos($id);
1✔
5134

5135
                if ($current['kudos'] != $kudos || $force) {
1✔
5136
                    $this->dbhm->preExec("REPLACE INTO users_kudos (userid, kudos, posts, chats, newsfeed, events, vols, facebook, platform) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);", [
1✔
5137
                        $id,
1✔
5138
                        $kudos,
1✔
5139
                        $posts,
1✔
5140
                        $chats,
1✔
5141
                        $newsfeed,
1✔
5142
                        $events,
1✔
5143
                        $vols,
1✔
5144
                        $facebook,
1✔
5145
                        $platform
1✔
5146
                    ], FALSE);
1✔
5147
                }
5148
            }
5149
        }
5150
    }
5151

5152
    public function topKudos($gid, $limit = 10)
5153
    {
5154
        $limit = intval($limit);
1✔
5155

5156
        $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✔
5157
            $gid,
1✔
5158
            User::ROLE_MEMBER
1✔
5159
        ]);
1✔
5160

5161
        $ret = [];
1✔
5162

5163
        foreach ($kudos as $k) {
1✔
5164
            $u = new User($this->dbhr, $this->dbhm, $k['userid']);
1✔
5165
            $atts = $u->getPublic();
1✔
5166
            $atts['email'] = $u->getEmailPreferred();
1✔
5167

5168
            $thisone = [
1✔
5169
                'user' => $atts,
1✔
5170
                'kudos' => $k
1✔
5171
            ];
1✔
5172

5173
            $ret[] = $thisone;
1✔
5174
        }
5175

5176
        return ($ret);
1✔
5177
    }
5178

5179
    public function possibleMods($gid, $limit = 10)
5180
    {
5181
        # We look for users who are not mods with top kudos who also:
5182
        # - active in last 60 days
5183
        # - not bouncing
5184
        # - using a location which is in the group area
5185
        # - have posted with the platform, as we don't want loyal users of TN or Yahoo.
5186
        # - have a Facebook login, as they are more likely to do publicity.
5187
        $limit = intval($limit);
1✔
5188
        $start = date('Y-m-d', strtotime("60 days ago"));
1✔
5189
        $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✔
5190
        $kudos = $this->dbhr->preQuery($sql, [
1✔
5191
            $gid,
1✔
5192
            User::ROLE_MEMBER
1✔
5193
        ]);
1✔
5194

5195
        $ret = [];
1✔
5196

5197
        foreach ($kudos as $k) {
1✔
5198
            $u = new User($this->dbhr, $this->dbhm, $k['userid']);
1✔
5199
            $atts = $u->getPublic();
1✔
5200
            $atts['email'] = $u->getEmailPreferred();
1✔
5201

5202
            $thisone = [
1✔
5203
                'user' => $atts,
1✔
5204
                'kudos' => $k
1✔
5205
            ];
1✔
5206

5207
            $ret[] = $thisone;
1✔
5208
        }
5209

5210
        return ($ret);
1✔
5211
    }
5212

5213
    public function requestExport($sync = FALSE)
5214
    {
5215
        $tag = Utils::randstr(64);
8✔
5216

5217
        # Flag sync ones as started to avoid window with background thread.
5218
        $sync = $sync ? "NOW()" : "NULL";
8✔
5219
        $this->dbhm->preExec("INSERT INTO users_exports (userid, tag, started) VALUES (?, ?, $sync);", [
8✔
5220
            $this->id,
8✔
5221
            $tag
8✔
5222
        ]);
8✔
5223

5224
        return ([$this->dbhm->lastInsertId(), $tag]);
8✔
5225
    }
5226

5227
    public function export($exportid, $tag)
5228
    {
5229
        $this->dbhm->preExec("UPDATE users_exports SET started = NOW() WHERE id = ? AND tag = ?;", [
7✔
5230
            $exportid,
7✔
5231
            $tag
7✔
5232
        ]);
7✔
5233

5234
        # For GDPR we support the ability for a user to export the data we hold about them.  Key points about this:
5235
        #
5236
        # - It needs to be at a high level of abstraction and understandable by the user, not just a cryptic data
5237
        #   dump.
5238
        # - It needs to include data provided by the user and data observed about the user, but not profiling
5239
        #   or categorisation based on that data.  This means that (for example) we need to return which
5240
        #   groups they have joined, but not whether joining those groups has flagged them up as a potential
5241
        #   spammer.
5242
        $ret = [];
7✔
5243
        error_log("...basic info");
7✔
5244

5245
        # Data in user table.
5246
        $d = [];
7✔
5247
        $d['Our_internal_ID_for_you'] = $this->getPrivate('id');
7✔
5248
        $d['Your_full_name'] = $this->getPrivate('fullname');
7✔
5249
        $d['Your_first_name'] = $this->getPrivate('firstname');
7✔
5250
        $d['Your_last_name'] = $this->getPrivate('lastname');
7✔
5251
        $d['Your_Yahoo_ID'] = $this->getPrivate('yahooid');
7✔
5252
        $d['Your_role_on_the_system'] = $this->getPrivate('systemrole');
7✔
5253
        $d['When_you_joined_the_site'] = Utils::ISODate($this->getPrivate('added'));
7✔
5254
        $d['When_you_last_accessed_the_site'] = Utils::ISODate($this->getPrivate('lastaccess'));
7✔
5255
        $d['When_we_last_checked_for_relevant_posts_for_you'] = Utils::ISODate($this->getPrivate('lastrelevantcheck'));
7✔
5256
        $d['Whether_your_email_is_bouncing'] = $this->getPrivate('bouncing') ? 'Yes' : 'No';
7✔
5257
        $d['Permissions_you_have_on_the_site'] = $this->getPrivate('permissions');
7✔
5258
        $d['Number_of_remaining_invitations_you_can_send_to_other_people'] = $this->getPrivate('invitesleft');
7✔
5259

5260
        $lastlocation = $this->user['lastlocation'];
7✔
5261

5262
        if ($lastlocation) {
7✔
5263
            $l = new Location($this->dbhr, $this->dbhm, $lastlocation);
×
5264
            $d['Last_location_you_posted_from'] = $l->getPrivate('name') . " (" . $l->getPrivate('lat') . ', ' . $l->getPrivate('lng') . ')';
×
5265
        }
5266

5267
        $settings = $this->getPrivate('settings');
7✔
5268

5269
        if ($settings) {
7✔
5270
            $settings = json_decode($settings, TRUE);
7✔
5271

5272
            $location = Utils::presdef('id', Utils::presdef('mylocation', $settings, []), NULL);
7✔
5273

5274
            if ($location) {
7✔
5275
                $l = new Location($this->dbhr, $this->dbhm, $location);
6✔
5276
                $d['Last_location_you_entered'] = $l->getPrivate('name') . ' (' . $l->getPrivate('lat') . ', ' . $l->getPrivate('lng') . ')';
6✔
5277
            }
5278

5279
            $notifications = Utils::pres('notifications', $settings);
7✔
5280

5281
            $d['Notifications']['Send_email_notifications_for_chat_messages'] = Utils::presdef('email', $notifications, TRUE) ? 'Yes' : 'No';
7✔
5282
            $d['Notifications']['Send_email_notifications_of_chat_messages_you_send'] = Utils::presdef('emailmine', $notifications, TRUE) ? 'Yes' : 'No';
7✔
5283
            $d['Notifications']['Send_push_notifications_to_web_browsers'] = Utils::presdef('push', $notifications, TRUE) ? 'Yes' : 'No';
7✔
5284
            $d['Notifications']['Send_Facebook_notifications'] = Utils::presdef('facebook', $notifications, TRUE) ? 'Yes' : 'No';
7✔
5285
            $d['Notifications']['Send_emails_about_notifications_on_the_site'] = Utils::presdef('notificationmails', $notifications, TRUE) ? 'Yes' : 'No';
7✔
5286

5287
            $d['Hide_profile_picture'] = Utils::presdef('useprofile', $settings, TRUE) ? 'Yes' : 'No';
7✔
5288

5289
            if ($this->isModerator()) {
7✔
5290
                $d['Show_members_that_you_are_a_moderator'] = Utils::pres('showmod', $settings) ? 'Yes' : 'No';
1✔
5291

5292
                switch (Utils::presdef('modnotifs', $settings, 4)) {
1✔
5293
                    case 24:
1✔
5294
                        $d['Send_notifications_of_active_mod_work'] = 'After 24 hours';
×
5295
                        break;
×
5296
                    case 12:
1✔
5297
                        $d['Send_notifications_of_active_mod_work'] = 'After 12 hours';
×
5298
                        break;
×
5299
                    case 4:
1✔
5300
                        $d['Send_notifications_of_active_mod_work'] = 'After 4 hours';
1✔
5301
                        break;
1✔
5302
                    case 2:
×
5303
                        $d['Send_notifications_of_active_mod_work'] = 'After 2 hours';
×
5304
                        break;
×
5305
                    case 1:
×
5306
                        $d['Send_notifications_of_active_mod_work'] = 'After 1 hours';
×
5307
                        break;
×
5308
                    case 0:
×
5309
                        $d['Send_notifications_of_active_mod_work'] = 'Immediately';
×
5310
                        break;
×
5311
                    case -1:
5312
                        $d['Send_notifications_of_active_mod_work'] = 'Never';
×
5313
                        break;
×
5314
                }
5315

5316
                switch (Utils::presdef('backupmodnotifs', $settings, 12)) {
1✔
5317
                    case 24:
1✔
5318
                        $d['Send_notifications_of_backup_mod_work'] = 'After 24 hours';
×
5319
                        break;
×
5320
                    case 12:
1✔
5321
                        $d['Send_notifications_of_backup_mod_work'] = 'After 12 hours';
1✔
5322
                        break;
1✔
5323
                    case 4:
×
5324
                        $d['Send_notifications_of_backup_mod_work'] = 'After 4 hours';
×
5325
                        break;
×
5326
                    case 2:
×
5327
                        $d['Send_notifications_of_backup_mod_work'] = 'After 2 hours';
×
5328
                        break;
×
5329
                    case 1:
×
5330
                        $d['Send_notifications_of_backup_mod_work'] = 'After 1 hours';
×
5331
                        break;
×
5332
                    case 0:
×
5333
                        $d['Send_notifications_of_backup_mod_work'] = 'Immediately';
×
5334
                        break;
×
5335
                    case -1:
5336
                        $d['Send_notifications_of_backup_mod_work'] = 'Never';
×
5337
                        break;
×
5338
                }
5339

5340
                $d['Show_members_that_you_are_a_moderator'] = Utils::presdef('showmod', $settings, TRUE) ? 'Yes' : 'No';
1✔
5341
            }
5342
        }
5343

5344
        # Invitations.  Only show what we sent; the outcome is not this user's business.
5345
        error_log("...invitations");
7✔
5346
        $invites = $this->listInvitations("1970-01-01");
7✔
5347
        $d['invitations'] = [];
7✔
5348

5349
        foreach ($invites as $invite) {
7✔
5350
            $d['invitations'][] = [
6✔
5351
                'email' => $invite['email'],
6✔
5352
                'date' => Utils::ISODate($invite['date'])
6✔
5353
            ];
6✔
5354
        }
5355

5356
        error_log("...emails");
7✔
5357
        $d['emails'] = $this->getEmails();
7✔
5358

5359
        foreach ($d['emails'] as &$email) {
7✔
5360
            $email['added'] = Utils::ISODate($email['added']);
1✔
5361

5362
            if ($email['validated']) {
1✔
5363
                $email['validated'] = Utils::ISODate($email['validated']);
×
5364
            }
5365
        }
5366

5367
        error_log("...logins");
7✔
5368
        $d['logins'] = $this->dbhr->preQuery("SELECT type, uid, added, lastaccess FROM users_logins WHERE userid = ?;", [
7✔
5369
            $this->id
7✔
5370
        ]);
7✔
5371

5372
        foreach ($d['logins'] as &$dd) {
7✔
5373
            $dd['added'] = Utils::ISODate($dd['added']);
7✔
5374
            $dd['lastaccess'] = Utils::ISODate($dd['lastaccess']);
7✔
5375
        }
5376

5377
        error_log("...memberships");
7✔
5378
        $d['memberships'] = $this->getMemberships();
7✔
5379

5380
        error_log("...memberships history");
7✔
5381
        $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✔
5382
        $membs = $this->dbhr->preQuery($sql, [$this->id]);
7✔
5383
        foreach ($membs as &$memb) {
7✔
5384
            $name = $memb['namefull'] ? $memb['namefull'] : $memb['nameshort'];
7✔
5385
            $memb['namedisplay'] = $name;
7✔
5386
            $memb['added'] = Utils::ISODate($memb['added']);
7✔
5387
        }
5388

5389
        $d['membershipshistory'] = $membs;
7✔
5390

5391
        error_log("...searches");
7✔
5392
        $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✔
5393
            $this->id
7✔
5394
        ]);
7✔
5395

5396
        foreach ($d['searches'] as &$s) {
7✔
5397
            $s['date'] = Utils::ISODate($s['date']);
×
5398
        }
5399

5400
        error_log("...alerts");
7✔
5401
        $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✔
5402
            $this->id
7✔
5403
        ]);
7✔
5404

5405
        foreach ($d['alerts'] as &$s) {
7✔
5406
            $s['responded'] = Utils::ISODate($s['responded']);
×
5407
        }
5408

5409
        error_log("...donations");
7✔
5410
        $d['donations'] = $this->dbhr->preQuery("SELECT * FROM users_donations WHERE userid = ? ORDER BY timestamp ASC;", [
7✔
5411
            $this->id
7✔
5412
        ]);
7✔
5413

5414
        foreach ($d['donations'] as &$s) {
7✔
UNCOV
5415
            $s['timestamp'] = Utils::ISODate($s['timestamp']);
×
5416
        }
5417

5418
        error_log("...bans");
7✔
5419
        $d['bans'] = [];
7✔
5420

5421
        $bans = $this->dbhr->preQuery("SELECT * FROM users_banned WHERE byuser = ?;", [
7✔
5422
            $this->id
7✔
5423
        ]);
7✔
5424

5425
        foreach ($bans as $ban) {
7✔
5426
            $g = Group::get($this->dbhr, $this->dbhm, $ban['groupid']);
1✔
5427
            $u = User::get($this->dbhr, $this->dbhm, $ban['userid']);
1✔
5428
            $d['bans'][] = [
1✔
5429
                'date' => Utils::ISODate($ban['date']),
1✔
5430
                'group' => $g->getName(),
1✔
5431
                'email' => $u->getEmailPreferred(),
1✔
5432
                'userid' => $ban['userid']
1✔
5433
            ];
1✔
5434
        }
5435

5436
        error_log("...spammers");
7✔
5437
        $d['spammers'] = $this->dbhr->preQuery("SELECT * FROM spam_users WHERE byuserid = ? ORDER BY added ASC;", [
7✔
5438
            $this->id
7✔
5439
        ]);
7✔
5440

5441
        foreach ($d['spammers'] as &$s) {
7✔
5442
            $s['added'] = Utils::ISODate($s['added']);
×
5443
            $u = User::get($this->dbhr, $this->dbhm, $s['userid']);
×
5444
            $s['email'] = $u->getEmailPreferred();
×
5445
        }
5446

5447
        $d['spamdomains'] = $this->dbhr->preQuery("SELECT domain, date FROM spam_whitelist_links WHERE userid = ?;", [
7✔
5448
            $this->id
7✔
5449
        ]);
7✔
5450

5451
        foreach ($d['spamdomains'] as &$s) {
7✔
5452
            $s['date'] = Utils::ISODate($s['date']);
×
5453
        }
5454

5455
        error_log("...images");
7✔
5456
        $images = $this->dbhr->preQuery("SELECT id, url FROM users_images WHERE userid = ?;", [
7✔
5457
            $this->id
7✔
5458
        ]);
7✔
5459

5460
        $d['images'] = [];
7✔
5461

5462
        foreach ($images as $image) {
7✔
5463
            if (Utils::pres('url', $image)) {
6✔
5464
                $d['images'][] = [
6✔
5465
                    'id' => $image['id'],
6✔
5466
                    'thumb' => $image['url']
6✔
5467
                ];
6✔
5468
            } else {
5469
                $a = new Attachment($this->dbhr, $this->dbhm, NULL, Attachment::TYPE_USER);
×
5470
                $d['images'][] = [
×
5471
                    'id' => $image['id'],
×
5472
                    'thumb' => $a->getPath(TRUE, $image['id'])
×
5473
                ];
×
5474
            }
5475
        }
5476

5477
        error_log("...notifications");
7✔
5478
        $d['notifications'] = $this->dbhr->preQuery("SELECT timestamp, url FROM users_notifications WHERE touser = ? AND seen = 1;", [
7✔
5479
            $this->id
7✔
5480
        ]);
7✔
5481

5482
        foreach ($d['notifications'] as &$n) {
7✔
5483
            $n['timestamp'] = Utils::ISODate($n['timestamp']);
×
5484
        }
5485

5486
        error_log("...addresses");
7✔
5487
        $d['addresses'] = [];
7✔
5488

5489
        $addrs = $this->dbhr->preQuery("SELECT * FROM users_addresses WHERE userid = ?;", [
7✔
5490
            $this->id
7✔
5491
        ]);
7✔
5492

5493
        foreach ($addrs as $addr) {
7✔
5494
            $a = new Address($this->dbhr, $this->dbhm, $addr['id']);
×
5495
            $d['addresses'][] = $a->getPublic();
×
5496
        }
5497

5498
        error_log("...events");
7✔
5499
        $d['communityevents'] = [];
7✔
5500

5501
        $events = $this->dbhr->preQuery("SELECT id FROM communityevents WHERE userid = ?;", [
7✔
5502
            $this->id
7✔
5503
        ]);
7✔
5504

5505
        foreach ($events as $event) {
7✔
5506
            $e = new CommunityEvent($this->dbhr, $this->dbhm, $event['id']);
×
5507
            $d['communityevents'][] = $e->getPublic();
×
5508
        }
5509

5510
        error_log("...volunteering");
7✔
5511
        $d['volunteering'] = [];
7✔
5512

5513
        $events = $this->dbhr->preQuery("SELECT id FROM volunteering WHERE userid = ?;", [
7✔
5514
            $this->id
7✔
5515
        ]);
7✔
5516

5517
        foreach ($events as $event) {
7✔
5518
            $e = new Volunteering($this->dbhr, $this->dbhm, $event['id']);
×
5519
            $d['volunteering'][] = $e->getPublic();
×
5520
        }
5521

5522
        error_log("...comments");
7✔
5523
        $d['comments'] = [];
7✔
5524
        $comms = $this->dbhr->preQuery("SELECT * FROM users_comments WHERE byuserid = ? ORDER BY date ASC;", [
7✔
5525
            $this->id
7✔
5526
        ]);
7✔
5527

5528
        foreach ($comms as &$comm) {
7✔
5529
            $u = User::get($this->dbhr, $this->dbhm, $comm['userid']);
1✔
5530
            $comm['email'] = $u->getEmailPreferred();
1✔
5531
            $comm['date'] = Utils::ISODate($comm['date']);
1✔
5532
            $d['comments'][] = $comm;
1✔
5533
        }
5534

5535
        error_log("...ratings");
7✔
5536
        $d['ratings'] = $this->getRated();
7✔
5537

5538
        error_log("...locations");
7✔
5539
        $d['locations'] = [];
7✔
5540

5541
        $locs = $this->dbhr->preQuery("SELECT * FROM locations_excluded WHERE userid = ?;", [
7✔
5542
            $this->id
7✔
5543
        ]);
7✔
5544

5545
        foreach ($locs as $loc) {
7✔
5546
            $g = Group::get($this->dbhr, $this->dbhm, $loc['groupid']);
×
5547
            $l = new Location($this->dbhr, $this->dbhm, $loc['locationid']);
×
5548
            $d['locations'][] = [
×
5549
                'group' => $g->getName(),
×
5550
                'location' => $l->getPrivate('name'),
×
5551
                'date' => Utils::ISODate($loc['date'])
×
5552
            ];
×
5553
        }
5554

5555
        error_log("...messages");
7✔
5556
        $msgs = $this->dbhr->preQuery("SELECT id FROM messages WHERE fromuser = ? ORDER BY arrival ASC;", [
7✔
5557
            $this->id
7✔
5558
        ]);
7✔
5559

5560
        $d['messages'] = [];
7✔
5561

5562
        foreach ($msgs as $msg) {
7✔
5563
            $m = new Message($this->dbhr, $this->dbhm, $msg['id']);
×
5564

5565
            # Show all info here even moderator attributes.  This wouldn't normally be shown to users, but none
5566
            # of it is confidential really.
5567
            $thisone = $m->getPublic(FALSE, FALSE, TRUE);
×
5568

5569
            if (count($thisone['groups']) > 0) {
×
5570
                $g = Group::get($this->dbhr, $this->dbhm, $thisone['groups'][0]['groupid']);
×
5571
                $thisone['groups'][0]['namedisplay'] = $g->getName();
×
5572
            }
5573

5574
            $d['messages'][] = $thisone;
×
5575
        }
5576

5577
        # Chats.  Can't use listForUser as that filters on various things and has a ModTools vs FD distinction, and
5578
        # we're interested in information we have provided.  So we get the chats mentioned in the roster (we have
5579
        # provided information about being online) and where we have sent or reviewed a chat message.
5580
        error_log("...chats");
7✔
5581
        $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✔
5582
            $this->id,
7✔
5583
            $this->id,
7✔
5584
            $this->id
7✔
5585
        ]);
7✔
5586

5587
        $d['chatrooms'] = [];
7✔
5588
        $count = 0;
7✔
5589

5590
        foreach ($chatids as $chatid) {
7✔
5591
            # We don't return the chat name because it's too slow to produce.
5592
            $r = new ChatRoom($this->dbhr, $this->dbhm, $chatid['id']);
6✔
5593
            $thisone = [
6✔
5594
                'id' => $chatid['id'],
6✔
5595
                'name' => $r->getPublic($this)['name'],
6✔
5596
                'messages' => []
6✔
5597
            ];
6✔
5598

5599
            $sql = "SELECT date, lastip FROM chat_roster WHERE `chatid` = ? AND userid = ?;";
6✔
5600
            $roster = $this->dbhr->preQuery($sql, [$chatid['id'], $this->id]);
6✔
5601
            foreach ($roster as $rost) {
6✔
5602
                $thisone['lastip'] = $rost['lastip'];
6✔
5603
                $thisone['date'] = Utils::ISODate($rost['date']);
6✔
5604
            }
5605

5606
            # Get the messages we have sent in this chat.
5607
            $msgs = $this->dbhr->preQuery("SELECT id FROM chat_messages WHERE chatid = ? AND (userid = ? OR reviewedby = ?);", [
6✔
5608
                $chatid['id'],
6✔
5609
                $this->id,
6✔
5610
                $this->id
6✔
5611
            ]);
6✔
5612

5613
            $userlist = NULL;
6✔
5614

5615
            foreach ($msgs as $msg) {
6✔
5616
                $cm = new ChatMessage($this->dbhr, $this->dbhm, $msg['id']);
6✔
5617
                $thismsg = $cm->getPublic(FALSE, $userlist);
6✔
5618

5619
                # Strip out most of the refmsg detail - it's not ours and we need to save volume of data.
5620
                $refmsg = Utils::pres('refmsg', $thismsg);
6✔
5621

5622
                if ($refmsg) {
6✔
5623
                    $thismsg['refmsg'] = [
×
5624
                        'id' => $msg['id'],
×
5625
                        'subject' => Utils::presdef('subject', $refmsg, NULL)
×
5626
                    ];
×
5627
                }
5628

5629
                $thismsg['mine'] = Utils::presdef('userid', $thismsg, NULL) == $this->id;
6✔
5630
                $thismsg['date'] = Utils::ISODate($thismsg['date']);
6✔
5631
                $thisone['messages'][] = $thismsg;
6✔
5632

5633
                $count++;
6✔
5634
//
5635
//                if ($count > 200) {
5636
//                    break 2;
5637
//                }
5638
            }
5639

5640
            if (count($thisone['messages']) > 0) {
6✔
5641
                $d['chatrooms'][] = $thisone;
6✔
5642
            }
5643
        }
5644

5645
        error_log("...newsfeed");
7✔
5646
        $newsfeeds = $this->dbhr->preQuery("SELECT * FROM newsfeed WHERE userid = ?;", [
7✔
5647
            $this->id
7✔
5648
        ]);
7✔
5649

5650
        $d['newsfeed'] = [];
7✔
5651

5652
        foreach ($newsfeeds as $newsfeed) {
7✔
5653
            $n = new Newsfeed($this->dbhr, $this->dbhm, $newsfeed['id']);
6✔
5654
            $thisone = $n->getPublic(FALSE, FALSE, FALSE, FALSE);
6✔
5655
            $d['newsfeed'][] = $thisone;
6✔
5656
        }
5657

5658
        $d['newsfeed_unfollows'] = $this->dbhr->preQuery("SELECT * FROM newsfeed_unfollow WHERE userid = ?;", [
7✔
5659
            $this->id
7✔
5660
        ]);
7✔
5661

5662
        foreach ($d['newsfeed_unfollows'] as &$dd) {
7✔
5663
            $dd['timestamp'] = Utils::ISODate($dd['timestamp']);
×
5664
        }
5665

5666
        $d['newsfeed_likes'] = $this->dbhr->preQuery("SELECT * FROM newsfeed_likes WHERE userid = ?;", [
7✔
5667
            $this->id
7✔
5668
        ]);
7✔
5669

5670
        foreach ($d['newsfeed_likes'] as &$dd) {
7✔
5671
            $dd['timestamp'] = Utils::ISODate($dd['timestamp']);
×
5672
        }
5673

5674
        $d['newsfeed_reports'] = $this->dbhr->preQuery("SELECT * FROM newsfeed_reports WHERE userid = ?;", [
7✔
5675
            $this->id
7✔
5676
        ]);
7✔
5677

5678
        foreach ($d['newsfeed_reports'] as &$dd) {
7✔
5679
            $dd['timestamp'] = Utils::ISODate($dd['timestamp']);
×
5680
        }
5681

5682
        $d['aboutme'] = $this->dbhr->preQuery("SELECT timestamp, text FROM users_aboutme WHERE userid = ? AND LENGTH(text) > 5;", [
7✔
5683
            $this->id
7✔
5684
        ]);
7✔
5685

5686
        foreach ($d['aboutme'] as &$dd) {
7✔
5687
            $dd['timestamp'] = Utils::ISODate($dd['timestamp']);
×
5688
        }
5689

5690
        error_log("...stories");
7✔
5691
        $d['stories'] = $this->dbhr->preQuery("SELECT date, headline, story FROM users_stories WHERE userid = ?;", [
7✔
5692
            $this->id
7✔
5693
        ]);
7✔
5694

5695
        foreach ($d['stories'] as &$dd) {
7✔
5696
            $dd['date'] = Utils::ISODate($dd['date']);
×
5697
        }
5698

5699
        $d['stories_likes'] = $this->dbhr->preQuery("SELECT storyid FROM users_stories_likes WHERE userid = ?;", [
7✔
5700
            $this->id
7✔
5701
        ]);
7✔
5702

5703
        error_log("...exports");
7✔
5704
        $d['exports'] = $this->dbhr->preQuery("SELECT userid, started, completed FROM users_exports WHERE userid = ?;", [
7✔
5705
            $this->id
7✔
5706
        ]);
7✔
5707

5708
        foreach ($d['exports'] as &$dd) {
7✔
5709
            $dd['started'] = Utils::ISODate($dd['started']);
7✔
5710
            $dd['completed'] = Utils::ISODate($dd['completed']);
7✔
5711
        }
5712

5713
        error_log("...logs");
7✔
5714
        $l = new Log($this->dbhr, $this->dbhm);
7✔
5715
        $ctx = NULL;
7✔
5716
        $d['logs'] = $l->get(NULL, NULL, NULL, NULL, NULL, NULL, PHP_INT_MAX, $ctx, $this->id);
7✔
5717

5718
        error_log("...add group to logs");
7✔
5719
        $loggroups = [];
7✔
5720
        foreach ($d['logs'] as &$log) {
7✔
5721
            if (Utils::pres('groupid', $log)) {
7✔
5722
                # Don't put the whole group info in there, as it is slow to get.
5723
                if (!array_key_exists($log['groupid'], $loggroups)) {
7✔
5724
                    $g = Group::get($this->dbhr, $this->dbhm, $log['groupid']);
7✔
5725

5726
                    if ($g->getId() == $log['groupid']) {
7✔
5727
                        $loggroups[$log['groupid']] = [
7✔
5728
                            'id' => $log['groupid'],
7✔
5729
                            'nameshort' => $g->getPrivate('nameshort'),
7✔
5730
                            'namedisplay' => $g->getName()
7✔
5731
                        ];
7✔
5732
                    } else {
5733
                        $loggroups[$log['groupid']] = [
×
5734
                            'id' => $log['groupid'],
×
5735
                            'nameshort' => "DeletedGroup{$log['groupid']}",
×
5736
                            'namedisplay' => "Deleted group #{$log['groupid']}"
×
5737
                        ];
×
5738
                    }
5739
                }
5740

5741
                $log['group'] = $loggroups[$log['groupid']];
7✔
5742
            }
5743
        }
5744

5745
        # Gift aid
5746
        $don = new Donations($this->dbhr, $this->dbhm);
7✔
5747
        $d['giftaid'] = $don->getGiftAid($this->id);
7✔
5748

5749
        $ret = $d;
7✔
5750

5751
        # There are some other tables with information which we don't return.  Here's what and why:
5752
        # - Not part of the current UI so can't have any user data
5753
        #     polls_users
5754
        # - Covered by data that we do return from other tables
5755
        #     messages_drafts, messages_history, messages_groups, messages_outcomes,
5756
        #     messages_promises, users_modmails, modnotifs, users_dashboard,
5757
        #     users_nudges
5758
        # - Transient logging data
5759
        #     logs_emails, logs_sql, logs_errors, logs_src
5760
        # - Not provided by the user themselves
5761
        #     user_comments, messages_reneged, spam_users, users_banned, users_stories_requested,
5762
        #     users_thanks
5763
        # - Inferred or derived data.  These are not considered to be provided by the user (see p10 of
5764
        #   http://ec.europa.eu/newsroom/document.cfm?doc_id=44099)
5765
        #     users_kudos, visualise
5766

5767
        # Compress the data in the DB because it can be huge.
5768
        #
5769
        error_log("...filter");
7✔
5770
        Utils::filterResult($ret);
7✔
5771
        error_log("...encode");
7✔
5772
        $data = json_encode($ret, JSON_PARTIAL_OUTPUT_ON_ERROR);
7✔
5773
        error_log("...encoded length " . strlen($data) . ", now compress");
7✔
5774
        $data = gzdeflate($data);
7✔
5775
        $this->dbhm->preExec("UPDATE users_exports SET completed = NOW(), data = ? WHERE id = ? AND tag = ?;", [
7✔
5776
            $data,
7✔
5777
            $exportid,
7✔
5778
            $tag
7✔
5779
        ]);
7✔
5780
        error_log("...completed, length " . strlen($data));
7✔
5781

5782
        return ($ret);
7✔
5783
    }
5784

5785
    function getExport($userid, $id, $tag)
5786
    {
5787
        $ret = NULL;
2✔
5788

5789
        $exports = $this->dbhr->preQuery("SELECT * FROM users_exports WHERE userid = ? AND id = ? AND tag = ?;", [
2✔
5790
            $userid,
2✔
5791
            $id,
2✔
5792
            $tag
2✔
5793
        ]);
2✔
5794

5795
        foreach ($exports as $export) {
2✔
5796
            $ret = $export;
2✔
5797
            $ret['requested'] = $ret['requested'] ? Utils::ISODate($ret['requested']) : NULL;
2✔
5798
            $ret['started'] = $ret['started'] ? Utils::ISODate($ret['started']) : NULL;
2✔
5799
            $ret['completed'] = $ret['completed'] ? Utils::ISODate($ret['completed']) : NULL;
2✔
5800

5801
            if ($ret['completed']) {
2✔
5802
                # This has completed.  Return the data.  Will be zapped in cron exports..
5803
                $ret['data'] = json_decode(gzinflate($export['data']), TRUE);
2✔
5804
                $ret['infront'] = 0;
2✔
5805
            } else {
5806
                # Find how many are in front of us.
5807
                $infront = $this->dbhr->preQuery("SELECT COUNT(*) AS count FROM users_exports WHERE id < ? AND completed IS NULL;", [
2✔
5808
                    $id
2✔
5809
                ]);
2✔
5810

5811
                $ret['infront'] = $infront[0]['count'];
2✔
5812
            }
5813
        }
5814

5815
        return ($ret);
2✔
5816
    }
5817

5818
    public function limbo() {
5819
        # We set the deleted attribute, which will cause the v2 Go API not to return any personal data.  The user
5820
        # can still log in, and potentially recover their account by calling session with PATCH of deleted = NULL.
5821
        # Otherwise a background script will purge their account after a couple of weeks.
5822
        #
5823
        # This allows us to handle the fairly common case of users deleting their accounts by mistake, or changing
5824
        # their minds.  This often happens because one-click unsubscribe in emails, which we need to have for
5825
        # delivery.
5826
        $this->dbhm->preExec("UPDATE users SET deleted = NOW() WHERE id = ?;", [
5✔
5827
            $this->id
5✔
5828
        ]);
5✔
5829

5830
        # Send email notification about account removal
5831
        $email = $this->getEmailPreferred();
5✔
5832
        if ($email) {
5✔
5833
            try {
5834
                $loader = new \Twig_Loader_Filesystem(IZNIK_BASE . '/mailtemplates/twig');
5✔
5835
                $twig = new \Twig_Environment($loader);
5✔
5836

5837
                list ($transport, $mailer) = Mail::getMailer();
5✔
5838

5839
                $html = $twig->render('limbo.html', [
5✔
5840
                    'site_url' => 'https://' . USER_SITE
5✔
5841
                ]);
5✔
5842

5843
                $message = \Swift_Message::newInstance()
5✔
5844
                    ->setSubject("Your Freegle account has been removed as requested")
5✔
5845
                    ->setFrom([NOREPLY_ADDR => SITE_NAME])
5✔
5846
                    ->setReplyTo(SUPPORT_ADDR)
5✔
5847
                    ->setTo($email)
5✔
5848
                    ->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✔
5849

5850
                # Add HTML in base-64 as default quoted-printable encoding leads to problems on
5851
                # Outlook.
5852
                $htmlPart = \Swift_MimePart::newInstance();
5✔
5853
                $htmlPart->setCharset('utf-8');
5✔
5854
                $htmlPart->setEncoder(new \Swift_Mime_ContentEncoder_Base64ContentEncoder);
5✔
5855
                $htmlPart->setContentType('text/html');
5✔
5856
                $htmlPart->setBody($html);
5✔
5857
                $message->attach($htmlPart);
5✔
5858

5859
                Mail::addHeaders($this->dbhr, $this->dbhm, $message, Mail::LIMBO, $this->id);
5✔
5860
                $this->sendIt($mailer, $message);
5✔
5861
            } catch (\Exception $e) {
×
5862
                error_log("Failed to send limbo email to user {$this->id}: " . $e->getMessage());
×
5863
            }
5864
        }
5865
    }
5866

5867
    public function processForgets($id = NULL) {
5868
        $count = 0;
1✔
5869

5870
        $idq = $id ? "AND id = $id" : "";
1✔
5871
        $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✔
5872

5873
        foreach ($users as $user) {
1✔
5874
            $u = new User($this->dbhr, $this->dbhm, $user['id']);
1✔
5875
            $u->forget('Grace period');
1✔
5876
            $count++;
1✔
5877
        }
5878

5879
        return $count;
1✔
5880
    }
5881

5882
    public function forget($reason)
5883
    {
5884
        # Wipe a user of personal data, for the GDPR right to be forgotten.  We don't delete the user entirely
5885
        # otherwise it would mess up the stats.
5886

5887
        # Clear name etc.
5888
        $this->setPrivate('firstname', NULL);
11✔
5889
        $this->setPrivate('lastname', NULL);
11✔
5890
        $this->setPrivate('fullname', "Deleted User #" . $this->id);
11✔
5891
        $this->setPrivate('settings', NULL);
11✔
5892
        $this->setPrivate('yahooid', NULL);
11✔
5893

5894
        # Delete emails which aren't ours.
5895
        $emails = $this->getEmails();
11✔
5896

5897
        foreach ($emails as $email) {
11✔
5898
            if (!$email['ourdomain']) {
8✔
5899
                $this->removeEmail($email['email']);
8✔
5900
            }
5901
        }
5902

5903
        # Delete all logins.
5904
        $this->dbhm->preExec("DELETE FROM users_logins WHERE userid = ?;", [
11✔
5905
            $this->id
11✔
5906
        ]);
11✔
5907

5908
        # Delete the content (but not subject) of any messages, and any email header information such as their
5909
        # name and email address.
5910
        $msgs = $this->dbhm->preQuery("SELECT id FROM messages WHERE fromuser = ? AND messages.type IN (?, ?);", [
11✔
5911
            $this->id,
11✔
5912
            Message::TYPE_OFFER,
11✔
5913
            Message::TYPE_WANTED
11✔
5914
        ]);
11✔
5915

5916
        foreach ($msgs as $msg) {
11✔
5917
            $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✔
5918
                $msg['id']
1✔
5919
            ]);
1✔
5920

5921
            $this->dbhm->preExec("UPDATE messages_groups SET deleted = 1 WHERE msgid = ?;", [
1✔
5922
                $msg['id']
1✔
5923
            ]);
1✔
5924

5925
            # Delete outcome comments that they've added - just about might have personal data.
5926
            $this->dbhm->preExec("UPDATE messages_outcomes SET comments = NULL WHERE msgid = ?;", [
1✔
5927
                $msg['id']
1✔
5928
            ]);
1✔
5929

5930
            $m = new Message($this->dbhr, $this->dbhm, $msg['id']);
1✔
5931

5932
            if (!$m->hasOutcome()) {
1✔
5933
                $m->withdraw('Withdrawn on user unsubscribe', NULL);
1✔
5934
            }
5935
        }
5936

5937
        # Remove all the content of all chat messages which they have sent (but not received).
5938
        $msgs = $this->dbhm->preQuery("SELECT id FROM chat_messages WHERE userid = ?;", [
11✔
5939
            $this->id
11✔
5940
        ]);
11✔
5941

5942
        foreach ($msgs as $msg) {
11✔
5943
            $this->dbhm->preExec("UPDATE chat_messages SET message = NULL WHERE id = ?;", [
1✔
5944
                $msg['id']
1✔
5945
            ]);
1✔
5946
        }
5947

5948
        # Delete completely any community events, volunteering opportunities, newsfeed posts, searches and stories
5949
        # they have created (their personal details might be in there), and any ratings by or about them.
5950
        $this->dbhm->preExec("DELETE FROM communityevents WHERE userid = ?;", [
11✔
5951
            $this->id
11✔
5952
        ]);
11✔
5953
        $this->dbhm->preExec("DELETE FROM volunteering WHERE userid = ?;", [
11✔
5954
            $this->id
11✔
5955
        ]);
11✔
5956
        $this->dbhm->preExec("DELETE FROM newsfeed WHERE userid = ?;", [
11✔
5957
            $this->id
11✔
5958
        ]);
11✔
5959
        $this->dbhm->preExec("DELETE FROM users_stories WHERE userid = ?;", [
11✔
5960
            $this->id
11✔
5961
        ]);
11✔
5962
        $this->dbhm->preExec("DELETE FROM users_searches WHERE userid = ?;", [
11✔
5963
            $this->id
11✔
5964
        ]);
11✔
5965
        $this->dbhm->preExec("DELETE FROM users_aboutme WHERE userid = ?;", [
11✔
5966
            $this->id
11✔
5967
        ]);
11✔
5968
        $this->dbhm->preExec("DELETE FROM ratings WHERE rater = ?;", [
11✔
5969
            $this->id
11✔
5970
        ]);
11✔
5971
        $this->dbhm->preExec("DELETE FROM ratings WHERE ratee = ?;", [
11✔
5972
            $this->id
11✔
5973
        ]);
11✔
5974

5975
        # Remove them from all groups.
5976
        $membs = $this->getMemberships();
11✔
5977

5978
        foreach ($membs as $memb) {
11✔
5979
            $this->removeMembership($memb['id']);
7✔
5980
        }
5981

5982
        # Delete any postal addresses
5983
        $this->dbhm->preExec("DELETE FROM users_addresses WHERE userid = ?;", [
11✔
5984
            $this->id
11✔
5985
        ]);
11✔
5986

5987
        # Delete any profile images
5988
        $this->dbhm->preExec("DELETE FROM users_images WHERE userid = ?;", [
11✔
5989
            $this->id
11✔
5990
        ]);
11✔
5991

5992
        # Remove any promises.
5993
        $this->dbhm->preExec("DELETE FROM messages_promises WHERE userid = ?;", [
11✔
5994
            $this->id
11✔
5995
        ]);
11✔
5996

5997
        $this->dbhm->preExec("UPDATE users SET forgotten = NOW(), tnuserid = NULL WHERE id = ?;", [
11✔
5998
            $this->id
11✔
5999
        ]);
11✔
6000

6001
        $this->dbhm->preExec("DELETE FROM sessions WHERE userid = ?;", [
11✔
6002
            $this->id
11✔
6003
        ]);
11✔
6004

6005
        $l = new Log($this->dbhr, $this->dbhm);
11✔
6006
        $l->log([
11✔
6007
            'type' => Log::TYPE_USER,
11✔
6008
            'subtype' => Log::SUBTYPE_DELETED,
11✔
6009
            'user' => $this->id,
11✔
6010
            'text' => $reason
11✔
6011
        ]);
11✔
6012
    }
6013

6014
    public function userRetention($userid = NULL)
6015
    {
6016
        # Find users who:
6017
        # - were added six months ago
6018
        # - are not on any groups
6019
        # - have not logged in for six months
6020
        # - are not on the spammer list
6021
        # - do not have mod notes
6022
        # - have no logs for six months
6023
        #
6024
        # We have no good reason to keep any data about them, and should therefore purge them.
6025
        $count = 0;
1✔
6026
        $userq = $userid ? " users.id = $userid AND " : '';
1✔
6027
        $mysqltime = date("Y-m-d", strtotime("6 months ago"));
1✔
6028
        
6029
        # First, delete users with @yahoogroups.com emails (test/old emails)
6030
        $yahoosql = "SELECT DISTINCT users.id FROM users 
1✔
6031
                     INNER JOIN users_emails ON users.id = users_emails.userid 
6032
                     WHERE $userq users_emails.email LIKE '%@yahoogroups.com' AND users.deleted IS NULL;";
1✔
6033
        $yahooUsers = $this->dbhr->preQuery($yahoosql);
1✔
6034
        
6035
        foreach ($yahooUsers as $user) {
1✔
6036
            error_log("Deleting Yahoo Groups user #{$user['id']}");
×
6037
            $u = new User($this->dbhr, $this->dbhm, $user['id']);
×
6038
            $u->delete();
×
6039
            $count++;
×
6040
            
6041
            # Prod garbage collection
6042
            User::clearCache();
×
6043
            gc_collect_cycles();
×
6044
        }
6045
        
6046
        $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✔
6047
        $users = $this->dbhr->preQuery($sql, [
1✔
6048
            User::SYSTEMROLE_USER
1✔
6049
        ]);
1✔
6050

6051
        foreach ($users as $user) {
1✔
6052
            $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✔
6053
                $user['id'],
1✔
6054
                Log::TYPE_USER,
1✔
6055
                Log::SUBTYPE_CREATED,
1✔
6056
                Log::SUBTYPE_DELETED
1✔
6057
            ]);
1✔
6058

6059
            error_log("#{$user['id']} Found logs " . count($logs) . " age " . (count($logs) > 0 ? $logs['0']['logsago'] : ' none '));
1✔
6060

6061
            if (count($logs) == 0 || $logs[0]['logsago'] > 90) {
1✔
6062
                error_log("...forget user #{$user['id']} " . (count($logs) > 0 ? $logs[0]['logsago'] : ''));
1✔
6063
                $u = new User($this->dbhr, $this->dbhm, $user['id']);
1✔
6064
                $u->forget('Inactive');
1✔
6065
                $count++;
1✔
6066
            }
6067

6068
            # Prod garbage collection, as we've seen high memory usage by this.
6069
            User::clearCache();
1✔
6070
            gc_collect_cycles();
1✔
6071
        }
6072

6073
        # The only reason for preserving deleted users is as a placeholder user for messages they sent.  If they
6074
        # don't have any messages, they can go.
6075
        $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✔
6076
            $mysqltime
1✔
6077
        ]);
1✔
6078

6079
        $total = count($ids);
1✔
6080
        $count = 0;
1✔
6081

6082
        foreach ($ids as $id) {
1✔
6083
            $u = new User($this->dbhr, $this->dbhm, $id['id']);
1✔
6084
            #error_log("...delete user #{$id['id']}");
6085
            $u->delete();
1✔
6086

6087
            $count++;
1✔
6088

6089
            if ($count % 1000 == 0) {
1✔
6090
                error_log("...delete $count / $total");
×
6091
            }
6092

6093

6094
            # Prod garbage collection, as we've seen high memory usage by this.
6095
            User::clearCache();
1✔
6096
            gc_collect_cycles();
1✔
6097
        }
6098

6099
        return ($count);
1✔
6100
    }
6101

6102
    public function recordActive()
6103
    {
6104
        # We record this on an hourly basis.  Avoid pointless mod ops for cluster health.
6105
        $now = date("Y-m-d H:00:00", time());
2✔
6106
        $already = $this->dbhr->preQuery("SELECT * FROM users_active WHERE userid = ? AND timestamp = ?;", [
2✔
6107
            $this->id,
2✔
6108
            $now
2✔
6109
        ]);
2✔
6110

6111
        if (count($already) == 0) {
2✔
6112
            $this->dbhm->background("INSERT IGNORE INTO users_active (userid, timestamp) VALUES ({$this->id}, '$now');");
2✔
6113
        }
6114
    }
6115

6116
    public function getActive()
6117
    {
6118
        $active = $this->dbhr->preQuery("SELECT * FROM users_active WHERE userid = ?;", [$this->id]);
1✔
6119
        return ($active);
1✔
6120
    }
6121

6122
    public function mostActive($gid, $limit = 20)
6123
    {
6124
        $limit = intval($limit);
1✔
6125
        $earliest = date("Y-m-d", strtotime("Midnight 30 days ago"));
1✔
6126

6127
        $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✔
6128
            $gid,
1✔
6129
            User::SYSTEMROLE_USER,
1✔
6130
            $earliest
1✔
6131
        ]);
1✔
6132

6133
        $ret = [];
1✔
6134

6135
        foreach ($users as $user) {
1✔
6136
            $u = User::get($this->dbhr, $this->dbhm, $user['userid']);
1✔
6137
            $thisone = $u->getPublic();
1✔
6138
            $thisone['groupid'] = $gid;
1✔
6139
            $thisone['email'] = $u->getEmailPreferred();
1✔
6140

6141
            if (Utils::pres('memberof', $thisone)) {
1✔
6142
                foreach ($thisone['memberof'] as $group) {
1✔
6143
                    if ($group['id'] == $gid) {
1✔
6144
                        $thisone['joined'] = $group['added'];
1✔
6145
                    }
6146
                }
6147
            }
6148

6149
            $ret[] = $thisone;
1✔
6150
        }
6151

6152
        return ($ret);
1✔
6153
    }
6154

6155
    public function setAboutMe($text) {
6156
        $this->dbhm->preExec("INSERT INTO users_aboutme (userid, text) VALUES (?, ?);", [
3✔
6157
            $this->id,
3✔
6158
            $text
3✔
6159
        ]);
3✔
6160

6161
        $this->dbhm->background("UPDATE users SET lastupdated = NOW() WHERE id = {$this->id};");
3✔
6162

6163
        return($this->dbhm->lastInsertId());
3✔
6164
    }
6165

6166
    public function rate($rater, $ratee, $rating, $reason = NULL, $text = NULL) {
6167
        $ret = NULL;
2✔
6168

6169
        if ($rater != $ratee) {
2✔
6170
            # Can't rate yourself.
6171
            $review = $rating == User::RATING_DOWN && $reason && $text;
2✔
6172
            $this->dbhm->preExec("REPLACE INTO ratings (rater, ratee, rating, reason, text, timestamp, reviewrequired) VALUES (?, ?, ?, ?, ?, NOW(), ?);", [
2✔
6173
                $rater,
2✔
6174
                $ratee,
2✔
6175
                $rating,
2✔
6176
                $reason,
2✔
6177
                $text,
2✔
6178
                $review
2✔
6179
            ]);
2✔
6180

6181
            $ret = $this->dbhm->lastInsertId();
2✔
6182

6183
            $this->dbhm->background("UPDATE users SET lastupdated = NOW() WHERE id IN ($rater, $ratee);");
2✔
6184
        }
6185

6186
        return($ret);
2✔
6187
    }
6188

6189
    public function getRatings($uids) {
6190
        $mysqltime = date("Y-m-d", strtotime("Midnight 182 days ago"));
131✔
6191
        $ret = [];
131✔
6192
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
131✔
6193
        $myid = $me ? $me->getId() : NULL;
131✔
6194

6195
        # We show visible ratings, ones we have made ourselves, or those from TN.
6196
        $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;";
131✔
6197
        $ratings = $this->dbhr->preQuery($sql, [ $myid, $myid ]);
131✔
6198

6199
        foreach ($uids as $uid) {
131✔
6200
            $ret[$uid] = [
131✔
6201
                User::RATING_UP => 0,
131✔
6202
                User::RATING_DOWN => 0,
131✔
6203
                User::RATING_MINE => NULL
131✔
6204
            ];
131✔
6205

6206
            foreach ($ratings as $rate) {
131✔
6207
                if ($rate['ratee'] == $uid) {
1✔
6208
                    $ret[$uid][$rate['rating']] = $rate['count'];
1✔
6209
                }
6210
            }
6211
        }
6212

6213
        $ratings = $this->dbhr->preQuery("SELECT rating, ratee FROM ratings WHERE ratee IN (" . implode(',', $uids) . ") AND rater = ? AND timestamp >= '$mysqltime';", [
131✔
6214
            $myid
131✔
6215
        ]);
131✔
6216

6217
        foreach ($uids as $uid) {
131✔
6218
            if ($myid != $this->id) {
131✔
6219
                # We can't rate ourselves, so don't bother checking.
6220

6221
                foreach ($ratings as $rating) {
80✔
6222
                    if ($rating['ratee'] == $uid) {
1✔
6223
                        $ret[$uid][User::RATING_MINE] = $rating['rating'];
1✔
6224
                    }
6225
                }
6226
            }
6227
        }
6228

6229
        return($ret);
131✔
6230
    }
6231

6232
    public function getAllRatings($since) {
6233
        $mysqltime = date("Y-m-d H:i:s", strtotime($since));
1✔
6234

6235
        $sql = "SELECT * FROM ratings WHERE timestamp >= ? AND visible = 1;";
1✔
6236
        $ratings = $this->dbhr->preQuery($sql, [
1✔
6237
            $mysqltime
1✔
6238
        ]);
1✔
6239

6240
        foreach ($ratings as &$rating) {
1✔
6241
            $rating['timestamp'] = Utils::ISODate($rating['timestamp']);
1✔
6242
        }
6243

6244
        return $ratings;
1✔
6245
    }
6246

6247
    public function getVisibleRatings($unreviewedonly, $since = '7 days ago') {
6248
        $mysqltime = date("Y-m-d H:i:s", strtotime($since));
3✔
6249
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
3✔
6250

6251
        $modships = $me->getModeratorships(NULL, TRUE);
3✔
6252

6253
        $ret = [];
3✔
6254
        $revq = $unreviewedonly ? " AND reviewrequired = 1" : '';
3✔
6255

6256
        if (count($modships)) {
3✔
6257
            $sql = "SELECT ratings.*, m1.groupid,
3✔
6258
       CASE WHEN u1.fullname IS NOT NULL THEN u1.fullname ELSE CONCAT(u1.firstname, ' ', u1.lastname) END AS raterdisplayname,
6259
       CASE WHEN u2.fullname IS NOT NULL THEN u2.fullname ELSE CONCAT(u2.firstname, ' ', u2.lastname) END AS rateedisplayname
6260
    FROM ratings 
6261
    INNER JOIN memberships m1 ON m1.userid = ratings.rater
6262
    INNER JOIN memberships m2 ON m2.userid = ratings.ratee
6263
    INNER JOIN users u1 ON ratings.rater = u1.id
6264
    INNER JOIN users u2 ON ratings.ratee = u2.id
6265
    WHERE ratings.timestamp >= ? AND 
6266
        m1.groupid IN (" . implode(',', $modships) . ") AND
3✔
6267
        m2.groupid IN (" . implode(',', $modships) . ") AND
3✔
6268
        m1.groupid = m2.groupid AND
6269
        ratings.rating IS NOT NULL 
6270
        $revq    
3✔
6271
        GROUP BY ratings.rater ORDER BY ratings.timestamp DESC;";
3✔
6272

6273
            $ret = $this->dbhr->preQuery($sql, [
3✔
6274
                $mysqltime
3✔
6275
            ]);
3✔
6276

6277
            foreach ($ret as &$r) {
3✔
6278
                $r['timestamp'] = Utils::ISODate($r['timestamp']);
1✔
6279
            }
6280
        }
6281

6282
        return $ret;
3✔
6283
    }
6284

6285
    public function ratingReviewed($ratingid) {
6286
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
1✔
6287

6288
        $unreviewed = $me->getVisibleRatings(TRUE);
1✔
6289

6290
        foreach ($unreviewed as $r) {
1✔
6291
            if ($r['id'] == $ratingid) {
1✔
6292
                $this->dbhm->preExec("UPDATE ratings SET reviewrequired = 0 WHERE id = ?;", [
1✔
6293
                    $ratingid
1✔
6294
                ]);
1✔
6295
            }
6296
        }
6297
    }
6298

6299
    public function getChanges($since) {
6300
        $mysqltime = date("Y-m-d H:i:s", strtotime($since));
1✔
6301

6302
        $users = $this->dbhr->preQuery("SELECT id, lastupdated FROM users WHERE lastupdated >= ?;", [
1✔
6303
            $mysqltime
1✔
6304
        ]);
1✔
6305

6306
        foreach ($users as &$user) {
1✔
6307
            $user['lastupdated'] = Utils::ISODate($user['lastupdated']);
1✔
6308
        }
6309

6310
        return $users;
1✔
6311
    }
6312

6313
    public function getRated() {
6314
        $rateds = $this->dbhr->preQuery("SELECT * FROM ratings WHERE rater = ?;", [
8✔
6315
            $this->id
8✔
6316
        ]);
8✔
6317

6318
        foreach ($rateds as &$rate) {
8✔
6319
            $rate['timestamp'] = Utils::ISODate($rate['timestamp']);
1✔
6320
        }
6321

6322
        return($rateds);
8✔
6323
    }
6324

6325
    public function getActiveSince($since, $createdbefore, $uid = NULL) {
6326
        $sincetime = date("Y-m-d H:i:s", strtotime($since));
1✔
6327
        $beforetime = date("Y-m-d H:i:s", strtotime($createdbefore));
1✔
6328
        $ids = $uid ? [
1✔
6329
            [
1✔
6330
                'id' => $uid
1✔
6331
            ]
1✔
6332
        ] : $this->dbhr->preQuery("SELECT id FROM users WHERE lastaccess >= ? AND added <= ?;", [
1✔
6333
            $sincetime,
1✔
6334
            $beforetime
1✔
6335
        ]);
1✔
6336

6337
        return(count($ids) ? array_filter(array_column($ids, 'id')) : []);
1✔
6338
    }
6339

6340
    public static function encodeId($id) {
6341
        # We're told that this is affecting our spam rating.  Let's see.
6342
        return '';
9✔
6343
//        $bin = base_convert($id, 10, 2);
6344
//        $bin = str_replace('0', '-', $bin);
6345
//        $bin = str_replace('1', '~', $bin);
6346
//        return($bin);
6347
    }
6348

6349
    public static function decodeId($enc) {
6350
        $enc = trim($enc);
×
6351
        $enc = str_replace('-', '0', $enc);
×
6352
        $enc = str_replace('~', '1', $enc);
×
6353
        $id  = base_convert($enc, 2, 10);
×
6354
        return($id);
×
6355
    }
6356

6357
    public function getCity()
6358
    {
6359
        $city = NULL;
21✔
6360

6361
        # Find the closest town
6362
        list ($lat, $lng, $loc) = $this->getLatLng(FALSE, TRUE);
21✔
6363

6364
        if ($lat || $lng) {
21✔
6365
            $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✔
6366
            #error_log("Get $sql, $lng, $lat");
6367
            $towns = $this->dbhr->preQuery($sql);
1✔
6368

6369
            foreach ($towns as $town) {
1✔
6370
                $city = $town['name'];
1✔
6371
            }
6372
        }
6373

6374
        return([ $city, $lat, $lng ]);
21✔
6375
    }
6376

6377
    public function microVolunteering() {
6378
        // Are we on a group where microvolunteering is enabled.
6379
        $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✔
6380
            $this->id
20✔
6381
        ]);
20✔
6382

6383
        return count($groups);
20✔
6384
    }
6385

6386
    public function getJobAds() {
6387
        # We want to show a few job ads from nearby.
6388
        $search = NULL;
35✔
6389
        $ret = '<span class="jobads">';
35✔
6390

6391
        list ($lat, $lng) = $this->getLatLng();
35✔
6392

6393
        if ($lat || $lng) {
35✔
6394
            $j = new Jobs($this->dbhr, $this->dbhm);
5✔
6395
            $jobs = $j->query($lat, $lng, 4);
5✔
6396

6397
            foreach ($jobs as $job) {
5✔
6398
                $loc = Utils::presdef('location', $job, '');
1✔
6399
                $title = "{$job['title']}" . ($loc !== ' ' ? " ($loc)" : '');
1✔
6400

6401
                # Link via our site to avoid spam trap warnings.
6402
                $url = "https://" . USER_SITE . "/job/{$job['id']}";
1✔
6403
                $ret .= '<a href="' . $url . '" target="_blank" style="color:black; font-weight:bold;">' . htmlentities($title) . '</a><br />';
1✔
6404
            }
6405
        }
6406

6407
        $ret .= '</span>';
35✔
6408

6409
        return([
35✔
6410
            'location' => $search,
35✔
6411
            'jobs' => $ret
35✔
6412
        ]);
35✔
6413
    }
6414

6415
    public function updateModMails($uid = NULL) {
6416
        # We maintain a count of recent modmails by scanning logs regularly, and pruning old ones.  This means we can
6417
        # find the value in a well-indexed way without the disk overhead of having a two-column index on logs.
6418
        #
6419
        # Ignore logs where the user is the same as the byuser - for example a user can delete their own posts, and we are
6420
        # only interested in things where a mod has done something to another user.
6421
        $mysqltime = date("Y-m-d H:i:s", strtotime("10 minutes ago"));
1✔
6422
        $uidq = $uid ? " AND user = $uid " : '';
1✔
6423

6424
        $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✔
6425
            $mysqltime
1✔
6426
        ]);
1✔
6427

6428
        foreach ($logs as $log) {
1✔
6429
            $this->dbhm->preExec("INSERT IGNORE INTO users_modmails (userid, logid, timestamp, groupid) VALUES (?,?,?,?);", [
1✔
6430
                $log['user'],
1✔
6431
                $log['id'],
1✔
6432
                $log['timestamp'],
1✔
6433
                $log['groupid']
1✔
6434
            ]);
1✔
6435
        }
6436

6437
        # Prune old ones.
6438
        $mysqltime = date("Y-m-d", strtotime("Midnight 30 days ago"));
1✔
6439
        $uidq2 = $uid ? " AND userid = $uid " : '';
1✔
6440

6441
        $logs = $this->dbhr->preQuery("SELECT id FROM users_modmails WHERE timestamp < ? $uidq2;", [
1✔
6442
            $mysqltime
1✔
6443
        ]);
1✔
6444

6445
        foreach ($logs as $log) {
1✔
6446
            $this->dbhm->preExec("DELETE FROM users_modmails WHERE id = ?;", [ $log['id'] ], FALSE);
×
6447
        }
6448
    }
6449

6450
    public function getModGroupsByActivity() {
6451
        $start = date('Y-m-d', strtotime("60 days ago"));
1✔
6452
        $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✔
6453
        return $this->dbhr->preQuery($sql, [
1✔
6454
            $this->id
1✔
6455
        ]);
1✔
6456
    }
6457

6458
    public function related($userlist) {
6459
        $userlist = array_unique($userlist);
2✔
6460

6461
        foreach ($userlist as $user1) {
2✔
6462
            foreach ($userlist as $user2) {
2✔
6463
                if ($user1 && $user2 && $user1 !== $user2) {
2✔
6464
                    # We may be passed user ids which no longer exist.
6465
                    $u1 = User::get($this->dbhr, $this->dbhm, $user1);
2✔
6466
                    $u2 = User::get($this->dbhr, $this->dbhm, $user2);
2✔
6467

6468
                    if ($u1->getId() && $u2->getId() && !$u1->isAdminOrSupport() && !$u2->isAdminOrSupport()) {
2✔
6469
                        $this->dbhm->background("INSERT INTO users_related (user1, user2) VALUES ($user1, $user2) ON DUPLICATE KEY UPDATE timestamp = NOW();");
2✔
6470
                    }
6471
                }
6472
            }
6473
        }
6474
    }
6475

6476
    public function getRelated($userid, $since = "30 days ago") {
6477
        $starttime = date("Y-m-d H:i:s", strtotime($since));
1✔
6478
        $users = $this->dbhr->preQuery("SELECT * FROM users_related WHERE user1 = ? AND timestamp >= '$starttime';", [
1✔
6479
            $userid
1✔
6480
        ]);
1✔
6481

6482
        return ($users);
1✔
6483
    }
6484

6485
    public function listRelated($groupids, &$ctx, $limit = 10) {
6486
        # The < condition ensures we don't duplicate during a single run.
6487
        $limit = intval($limit);
1✔
6488
        $ret = [];
1✔
6489
        $backstop = 100;
1✔
6490

6491
        do {
6492
            $ctx = $ctx ? $ctx : [ 'id'  => NULL ];
1✔
6493

6494
            if ($groupids && count($groupids)) {
1✔
6495
                $ctxq = ($ctx && intval($ctx['id'])) ? (" WHERE id < " . intval($ctx['id'])) : '';
1✔
6496
                $groupq = "(" . implode(',', $groupids) . ")";
1✔
6497
                $sql = "SELECT DISTINCT id, user1, user2 FROM (
1✔
6498
SELECT users_related.id, user1, user2, memberships.groupid FROM users_related 
6499
INNER JOIN memberships ON users_related.user1 = memberships.userid 
6500
INNER JOIN users u1 ON users_related.user1 = u1.id AND u1.deleted IS NULL AND u1.systemrole = 'User'
6501
WHERE 
6502
user1 < user2 AND
6503
notified = 0 AND
6504
memberships.groupid IN $groupq UNION
1✔
6505
SELECT users_related.id, user1, user2, memberships.groupid FROM users_related 
6506
INNER JOIN memberships ON users_related.user2 = memberships.userid 
6507
INNER JOIN users u2 ON users_related.user2 = u2.id AND u2.deleted IS NULL AND u2.systemrole = 'User'
6508
WHERE 
6509
user1 < user2 AND
6510
notified = 0 AND
6511
memberships.groupid IN $groupq 
1✔
6512
) t $ctxq ORDER BY id DESC LIMIT $limit;";
1✔
6513
                $members = $this->dbhr->preQuery($sql);
1✔
6514
            } else {
6515
                $ctxq = ($ctx && intval($ctx['id'])) ? (" AND users_related.id < " . intval($ctx['id'])) : '';
1✔
6516
                $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✔
6517
                $members = $this->dbhr->preQuery($sql, NULL, FALSE, FALSE);
1✔
6518
            }
6519

6520
            $uids1 = array_column($members, 'user1');
1✔
6521
            $uids2 = array_column($members, 'user2');
1✔
6522

6523
            $related = [];
1✔
6524
            foreach ($members as $member) {
1✔
6525
                $related[$member['user1']] = $member['user2'];
1✔
6526
                $ctx['id'] = $member['id'];
1✔
6527
            }
6528

6529
            $users = $this->getPublicsById(array_merge($uids1, $uids2));
1✔
6530

6531
            foreach ($users as &$user1) {
1✔
6532
                if (Utils::pres($user1['id'], $related)) {
1✔
6533
                    $thisone = $user1;
1✔
6534

6535
                    foreach ($users as $user2) {
1✔
6536
                        if ($user2['id'] == $related[$user1['id']]) {
1✔
6537
                            $user2['userid'] = $user2['id'];
1✔
6538
                            $thisone['relatedto'] = $user2;
1✔
6539
                            break;
1✔
6540
                        }
6541
                    }
6542

6543
                    $logins = $this->getLogins(FALSE, $thisone['id'], TRUE);
1✔
6544
                    $rellogins = $this->getLogins(FALSE, $thisone['relatedto']['id'], TRUE);
1✔
6545

6546
                    if ($thisone['deleted'] ||
1✔
6547
                        $thisone['relatedto']['deleted'] ||
1✔
6548
                        $thisone['systemrole'] != User::SYSTEMROLE_USER ||
1✔
6549
                        $thisone['relatedto']['systemrole'] != User::SYSTEMROLE_USER ||
1✔
6550
                        !count($logins) ||
1✔
6551
                        !count($rellogins)) {
1✔
6552
                        # No sense in telling people about these.
6553
                        #
6554
                        # If there are n valid login types for one of the users - no way they can log in again so no point notifying.
6555
                        $this->dbhm->preExec("UPDATE users_related SET notified = 1 WHERE (user1 = ? AND user2 = ?) OR (user1 = ? AND user2 = ?);", [
1✔
6556
                            $thisone['id'],
1✔
6557
                            $thisone['relatedto']['id'],
1✔
6558
                            $thisone['relatedto']['id'],
1✔
6559
                            $thisone['id']
1✔
6560
                        ]);
1✔
6561
                    } else {
6562
                        $thisone['userid'] = $thisone['id'];
1✔
6563
                        $thisone['logins'] = $logins;
1✔
6564
                        $thisone['relatedto']['logins'] = $rellogins;
1✔
6565

6566
                        $ret[] = $thisone;
1✔
6567
                    }
6568
                }
6569
            }
6570

6571
            $backstop--;
1✔
6572
        } while ($backstop > 0 && count($ret) < $limit && count($members));
1✔
6573

6574
        return $ret;
1✔
6575
    }
6576

6577
    public function getExpectedReplies($uids, $since = ChatRoom::ACTIVELIM, $grace = ChatRoom::REPLY_GRACE) {
6578
        # We count replies where the user has been active since the reply was requested, which means they've had
6579
        # a chance to reply, plus a grace period in minutes, so that if they're active right now we don't penalise them.
6580
        #
6581
        # $since here has to match the value in ChatRoom::
6582
        $starttime = date("Y-m-d H:i:s", strtotime($since));
131✔
6583
        $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) >= ?", [
131✔
6584
            $grace
131✔
6585
        ]);
131✔
6586

6587
        return($replies);
131✔
6588
    }
6589

6590
    public function listExpectedReplies($uid, $since = ChatRoom::ACTIVELIM, $grace = ChatRoom::REPLY_GRACE) {
6591
        # We count replies where the user has been active since the reply was requested, which means they've had
6592
        # a chance to reply, plus a grace period in minutes, so that if they're active right now we don't penalise them.
6593
        #
6594
        # $since here has to match the value in ChatRoom::
6595
        $starttime = date("Y-m-d H:i:s", strtotime($since));
20✔
6596
        $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✔
6597
            $uid,
20✔
6598
            $grace
20✔
6599
        ]);
20✔
6600

6601
        $ret = [];
20✔
6602

6603
        if (count($replies)) {
20✔
6604
            $me = Session::whoAmI($this->dbhr, $this->dbhm);
1✔
6605
            $myid = $me ? $me->getId() : NULL;
1✔
6606

6607
            $r = new ChatRoom($this->dbhr, $this->dbhm);
1✔
6608
            $rooms = $r->fetchRooms(array_column($replies, 'chatid'), $myid, TRUE);
1✔
6609

6610
            foreach ($rooms as $room) {
1✔
6611
                $ret[] = [
1✔
6612
                    'id' => $room['id'],
1✔
6613
                    'name' => $room['name']
1✔
6614
                ];
1✔
6615
            }
6616
        }
6617

6618
        return $ret;
20✔
6619
    }
6620
    
6621
    public function getWorkCounts($groups = NULL) {
6622
        # Tell them what mod work there is.  Similar code in Notifications.
6623
        $ret = [];
28✔
6624
        $total = 0;
28✔
6625

6626
        $national = $this->hasPermission(User::PERM_NATIONAL_VOLUNTEERS);
28✔
6627

6628
        if ($national) {
28✔
6629
            $v = new Volunteering($this->dbhr, $this->dbhm);
1✔
6630
            $ret['pendingvolunteering'] = $v->systemWideCount();
1✔
6631
        }
6632

6633
        $s = new Spam($this->dbhr, $this->dbhm);
28✔
6634
        $spamcounts = $s->collectionCounts();
28✔
6635
        $ret['spammerpendingadd'] = $spamcounts[Spam::TYPE_PENDING_ADD];
28✔
6636
        $ret['spammerpendingremove'] = $spamcounts[Spam::TYPE_PENDING_REMOVE];
28✔
6637

6638
        if ($this->hasPermission(User::PERM_GIFTAID)) {
28✔
6639
            $d = new Donations($this->dbhr, $this->dbhm);
1✔
6640
            $ret['giftaid'] = $d->countGiftAidReview();
1✔
6641
        }
6642

6643
        if (!$groups) {
28✔
6644
            $groups = $this->getMemberships(FALSE, NULL, TRUE, TRUE, $this->id, FALSE);
14✔
6645
        }
6646

6647
        foreach ($groups as &$group) {
28✔
6648
            if (Utils::pres('work', $group)) {
22✔
6649
                foreach ($group['work'] as $key => $work) {
20✔
6650
                    if (Utils::pres($key, $ret)) {
20✔
6651
                        $ret[$key] += $work;
2✔
6652
                    } else {
6653
                        $ret[$key] = $work;
20✔
6654
                    }
6655
                }
6656
            }
6657
        }
6658

6659
        $s = new Story($this->dbhr, $this->dbhm);
28✔
6660
        $ret['stories'] = $s->getReviewCount(FALSE, $this, $groups);
28✔
6661
        $ret['newsletterstories'] = $this->hasPermission(User::PERM_NEWSLETTER) ? $s->getReviewCount(TRUE) : 0;
28✔
6662

6663
        // All the types of work which are worth nagging about.
6664
        $worktypes = [
28✔
6665
            'pendingvolunteering',
28✔
6666
            'chatreview',
28✔
6667
            'relatedmembers',
28✔
6668
            'stories',
28✔
6669
            'newsletterstories',
28✔
6670
            'pending',
28✔
6671
            'spam',
28✔
6672
            'pendingmembers',
28✔
6673
            'pendingevents',
28✔
6674
            'spammembers',
28✔
6675
            'editreview',
28✔
6676
            'pendingadmins'
28✔
6677
        ];
28✔
6678

6679
        if ($this->isAdminOrSupport()) {
28✔
6680
            $worktypes[] = 'spammerpendingadd';
1✔
6681
            $worktypes[] = 'spammerpendingremove';
1✔
6682
        }
6683

6684
        foreach ($worktypes as $key) {
28✔
6685
            $total += Utils::presdef($key, $ret, 0);
28✔
6686
        }
6687

6688
        $ret['total'] = $total;
28✔
6689

6690
        return $ret;
28✔
6691
    }
6692

6693
    public function ratingVisibility($since = "1 hour ago") {
6694
        $mysqltime = date("Y-m-d", strtotime($since));
1✔
6695

6696
        $ratings = $this->dbhr->preQuery("SELECT * FROM ratings WHERE timestamp >= ?;", [
1✔
6697
            $mysqltime
1✔
6698
        ]);
1✔
6699

6700
        foreach ($ratings as $rating) {
1✔
6701
            # A rating is visible to others if there is a chat between the two members, and
6702
            # - the ratee replied to a post, or
6703
            # - there is at least one message from each of them.
6704
            # This means that has been an exchange substantial enough for the rating not to just be frivolous.  It
6705
            # deliberately excludes interactions on ChitChat, where we have seen some people go a bit overboard on
6706
            # rating people.
6707
            $visible = FALSE;
1✔
6708
            #error_log("Check {$rating['rater']} rating of {$rating['ratee']}");
6709

6710
            $chats = $this->dbhr->preQuery("SELECT id FROM chat_rooms WHERE (user1 = ? AND user2 = ?) OR (user2 = ? AND user1 = ?)", [
1✔
6711
                $rating['rater'],
1✔
6712
                $rating['ratee'],
1✔
6713
                $rating['rater'],
1✔
6714
                $rating['ratee'],
1✔
6715
            ]);
1✔
6716

6717
            foreach ($chats as $chat) {
1✔
6718
                $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✔
6719
                    $chat['id']
1✔
6720
                ]);
1✔
6721

6722
                if ($distincts[0]['count'] >= 2) {
1✔
6723
                    #error_log("At least one real message from each of them in {$chat['id']}");
6724
                    $visible = TRUE;
1✔
6725
                } else {
6726
                    $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✔
6727
                        $chat['id'],
1✔
6728
                        $rating['ratee']
1✔
6729
                    ]);
1✔
6730

6731
                    if ($replies[0]['count']) {
1✔
6732
                        #error_log("Significant reply from {$rating['ratee']} in {$chat['id']}");
6733
                        $visible = TRUE;
1✔
6734
                    }
6735
                }
6736
            }
6737

6738
            #error_log("Use {$rating['rating']} from {$rating['rater']} ? " . ($visible ? 'yes': 'no'));
6739
            $oldvisible = intval($rating['visible']) ? TRUE : FALSE;
1✔
6740

6741
            if ($visible != $oldvisible) {
1✔
6742
                $this->dbhm->preExec("UPDATE ratings SET visible = ?, timestamp = NOW() WHERE id = ?;", [
1✔
6743
                    $visible,
1✔
6744
                    $rating['id']
1✔
6745
                ]);
1✔
6746
            }
6747
        }
6748
    }
6749

6750
    public function unban($groupid) {
6751
        $this->dbhm->preExec("DELETE FROM users_banned WHERE userid = ? AND groupid = ?;", [
4✔
6752
            $this->id,
4✔
6753
            $groupid
4✔
6754
        ]);
4✔
6755
    }
6756

6757
    public function hasFacebookLogin() {
6758
        $logins = $this->getLogins();
3✔
6759
        $ret = FALSE;
3✔
6760

6761
        foreach ($logins as $login) {
3✔
6762
            if ($login['type'] == User::LOGIN_FACEBOOK) {
3✔
6763
                $ret = TRUE;
×
6764
            }
6765
        }
6766

6767
        return $ret;
3✔
6768
    }
6769

6770
    public function memberReview($groupid, $request, $reason) {
6771
        $mysqltime = date('Y-m-d H:i');
6✔
6772

6773
        if ($request) {
6✔
6774
            # Requesting review.  Leave reviewedat unchanged, so that we can use it to avoid asking too
6775
            # frequently.
6776
            $this->setMembershipAtt($groupid, 'reviewreason', $reason);
4✔
6777
            $this->setMembershipAtt($groupid, 'reviewrequestedat', $mysqltime);
4✔
6778
        } else {
6779
            # We have reviewed.  Note that they might have been removed, in which case the set will do nothing.
6780
            $this->setMembershipAtt($groupid, 'reviewrequestedat', NULL);
3✔
6781
            $this->setMembershipAtt($groupid, 'reviewedat', $mysqltime);
3✔
6782
        }
6783
    }
6784

6785
    private function checkSupporterSettings($settings) {
6786
        $ret = TRUE;
77✔
6787

6788
        if ($settings) {
77✔
6789
            $s = json_decode($settings, TRUE);
15✔
6790

6791
            if ($s && array_key_exists('hidesupporter', $s)) {
15✔
6792
                $ret = !$s['hidesupporter'];
1✔
6793
            }
6794
        }
6795

6796
        return $ret;
77✔
6797
    }
6798

6799
    public function getSupporters(&$rets, $users) {
6800
        $idsleft = [];
303✔
6801

6802
        foreach ($rets as $userid => $ret) {
303✔
6803
            if (Utils::pres($userid, $users)) {
264✔
6804
                if (array_key_exists('supporter', $users[$userid])) {
12✔
6805
                    $rets[$userid]['supporter'] = $users[$userid]['supporter'];
3✔
6806
                    $rets[$userid]['donor'] = Utils::presdef('donor', $users[$userid], FALSE);
12✔
6807
                }
6808
            } else {
6809
                $idsleft[] = $userid;
259✔
6810
            }
6811
        }
6812

6813
        if (count($idsleft)) {
303✔
6814
            $me = Session::whoAmI($this->dbhr, $this->dbhm);
259✔
6815
            $myid = $me ? $me->getId() : null;
259✔
6816

6817
            # A supporter is a mod, someone who has donated recently, or done microvolunteering recently.
6818
            if (count($idsleft)) {
259✔
6819
                $start = date('Y-m-d', strtotime("360 days ago"));
259✔
6820
                $info = $this->dbhr->preQuery(
259✔
6821
                    "SELECT DISTINCT users.id AS userid, settings, systemrole FROM users 
259✔
6822
    LEFT JOIN microactions ON users.id = microactions.userid
6823
    LEFT JOIN users_donations ON users_donations.userid = users.id 
6824
    WHERE users.id IN (" . implode(
259✔
6825
                        ',',
259✔
6826
                        $idsleft
259✔
6827
                    ) . ") AND 
259✔
6828
                    (systemrole IN (?, ?, ?) OR microactions.timestamp >= ? OR users_donations.timestamp >= ?);",
259✔
6829
                    [
259✔
6830
                        User::SYSTEMROLE_ADMIN,
259✔
6831
                        User::SYSTEMROLE_SUPPORT,
259✔
6832
                        User::SYSTEMROLE_MODERATOR,
259✔
6833
                        $start,
259✔
6834
                        $start
259✔
6835
                    ]
259✔
6836
                );
259✔
6837

6838
                $found = [];
259✔
6839

6840
                foreach ($info as $i) {
259✔
6841
                    $rets[$i['userid']]['supporter'] = $this->checkSupporterSettings($i['settings']);
77✔
6842
                    $found[] = $i['userid'];
77✔
6843
                }
6844

6845
                $left = array_diff($idsleft, $found);
259✔
6846

6847
                # If we are one of the users, then we want to return whether we are a donor.
6848
                if (in_array($myid, $idsleft)) {
259✔
6849
                    $left[] = $myid;
144✔
6850
                    $left = array_filter(array_unique($left));
144✔
6851
                }
6852

6853
                if (count($left)) {
259✔
6854
                    $info = $this->dbhr->preQuery(
257✔
6855
                        "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(
257✔
6856
                            ',',
257✔
6857
                            $left
257✔
6858
                        ) . ") GROUP BY TransactionType;",
257✔
6859
                        [
257✔
6860
                            $start
257✔
6861
                        ]
257✔
6862
                    );
257✔
6863

6864
                    foreach ($info as $i) {
257✔
6865
                        $rets[$i['userid']]['supporter'] = $this->checkSupporterSettings($i['settings']);
3✔
6866

6867
                        if ($i['userid'] == $myid) {
3✔
6868
                            # Only return this info for ourselves, otherwise it's a privacy leak.
6869
                            $rets[$i['userid']]['donor'] = TRUE;
3✔
6870

6871
                            if ($i['TransactionType'] == 'subscr_payment' || $i['TransactionType'] == 'recurring_payment') {
3✔
6872
                                $rets[$i['userid']]['donorrecurring'] = TRUE;
1✔
6873
                            }
6874
                        }
6875
                    }
6876
                }
6877
            }
6878
        }
6879
    }
6880

6881
    public function obfuscateEmail($email) {
6882
        $p = strpos($email, '@');
2✔
6883
        $q = strpos($email, 'privaterelay.appleid.com');
2✔
6884

6885
        if ($q) {
2✔
6886
            $email = 'Your Apple ID';
1✔
6887
        } else {
6888
            # For very short emails, we just show the first character.
6889
            if ($p <= 3) {
2✔
6890
                $email = substr($email, 0, 1) . "***" . substr($email, $p);
1✔
6891
            } else if ($p < 10) {
2✔
6892
                $email = substr($email, 0, 1) . "***" . substr($email, $p - 1);
2✔
6893
            } else {
6894
                $email = substr($email, 0, 3) . "***" . substr($email, $p - 3);
1✔
6895
            }
6896
        }
6897

6898
        return $email;
2✔
6899
    }
6900

6901
    public function setSimpleMail($simplemail) {
6902
        $s = $this->getPrivate('settings');
2✔
6903

6904
        if ($s) {
2✔
6905
            $settings = json_decode($s, TRUE);
1✔
6906
        } else {
6907
            $settings = [];
2✔
6908
        }
6909

6910
        $this->dbhm->beginTransaction();
2✔
6911

6912
        switch ($simplemail) {
6913
            case User::SIMPLE_MAIL_NONE: {
6914
                # No digests, no events/volunteering.
6915
                # No relevant or newsletters.
6916
                # No email notifications.
6917
                # No enagement.
6918
                $this->dbhm->preExec("UPDATE memberships SET emailfrequency = 0, eventsallowed = 0, volunteeringallowed = 0 WHERE userid = ?;", [
2✔
6919
                    $this->id
2✔
6920
                ]);
2✔
6921

6922
                $this->dbhm->preExec("UPDATE users SET relevantallowed = 0,  newslettersallowed = 0 WHERE id = ?;", [
2✔
6923
                    $this->id
2✔
6924
                ]);
2✔
6925

6926
                $settings['notifications']['email'] = FALSE;
2✔
6927
                $settings['notifications']['emailmine'] = FALSE;
2✔
6928
                $settings['notificationmails']= FALSE;
2✔
6929
                $settings['engagement'] = FALSE;
2✔
6930
                break;
2✔
6931
            }
6932
            case User::SIMPLE_MAIL_BASIC: {
6933
                # Daily digests, no events/volunteering.
6934
                # No relevant or newsletters.
6935
                # Chat email notifications.
6936
                # No enagement.
6937
                $this->dbhm->preExec("UPDATE memberships SET emailfrequency = 24, eventsallowed = 0, volunteeringallowed = 0 WHERE userid = ?;", [
1✔
6938
                    $this->id
1✔
6939
                ]);
1✔
6940

6941
                $this->dbhm->preExec("UPDATE users SET relevantallowed = 0,  newslettersallowed = 0 WHERE id = ?;", [
1✔
6942
                    $this->id
1✔
6943
                ]);
1✔
6944

6945
                $settings['notifications']['email'] = TRUE;
1✔
6946
                $settings['notifications']['emailmine'] = FALSE;
1✔
6947
                $settings['notificationmails']= FALSE;
1✔
6948
                $settings['engagement']= FALSE;
1✔
6949
                break;
1✔
6950
            }
6951
            case User::SIMPLE_MAIL_FULL: {
6952
                # Immediate mails, events/volunteering.
6953
                # Relevant and newsletters.
6954
                # Email notifications.
6955
                # Enagement.
6956
                $this->dbhm->preExec("UPDATE memberships SET emailfrequency = -1, eventsallowed = 1, volunteeringallowed = 1 WHERE userid = ?;", [
1✔
6957
                    $this->id
1✔
6958
                ]);
1✔
6959

6960
                $this->dbhm->preExec("UPDATE users SET relevantallowed = 1,  newslettersallowed = 1 WHERE id = ?;", [
1✔
6961
                    $this->id
1✔
6962
                ]);
1✔
6963

6964
                $settings['notifications']['email'] = TRUE;
1✔
6965
                $settings['notifications']['emailmine'] = FALSE;
1✔
6966
                $settings['notificationmails']= TRUE;
1✔
6967
                $settings['engagement']= TRUE;
1✔
6968
                break;
1✔
6969
            }
6970
        }
6971

6972
        $settings['simplemail'] = $simplemail;
2✔
6973

6974
        $this->setPrivate('settings', json_encode($settings));
2✔
6975

6976
        # Holiday no longer exposed so turn off.
6977
        $this->dbhm->preExec("UPDATE users SET onholidaytill = NULL, settings = ? WHERE id = ?;", [
2✔
6978
            json_encode($settings),
2✔
6979
            $this->id
2✔
6980
        ]);
2✔
6981

6982
        $this->dbhm->commit();
2✔
6983
    }
6984

6985
    public function processMemberships($g = NULL) {
6986
        $memberships = $this->dbhr->preQuery("SELECT id FROM `memberships_history` WHERE processingrequired = 1 ORDER BY id ASC;");
6✔
6987

6988
        foreach ($memberships as $membership) {
6✔
6989
            $this->processMembership($membership['id'], $g);
6✔
6990
        }
6991
    }
6992

6993
    public function processMembership($id, $g) {
6994
        $memberships = $this->dbhr->preQuery("SELECT * FROM memberships_history WHERE id = ?;",[
6✔
6995
            $id
6✔
6996
        ]);
6✔
6997

6998
        foreach ($memberships as $membership) {
6✔
6999
            $groupid = $membership['groupid'];
6✔
7000
            $userid = $membership['userid'];
6✔
7001
            $collection = $membership['collection'];
6✔
7002

7003
            $g = $g ? $g : Group::get($this->dbhr, $this->dbhm, $groupid);
6✔
7004

7005
            # The membership didn't already exist.  We might want to send a welcome mail.
7006
            if ($g->getPrivate('welcomemail') && $collection == MembershipCollection::APPROVED && $g->getPrivate('onhere')) {
6✔
7007
                # They are now approved.  We need to send a per-group welcome mail.
7008
                try {
7009
                    $g->sendWelcome($userid, FALSE);
2✔
7010
                } catch (Exception $e) {
×
7011
                    error_log("Welcome failed: " . $e->getMessage());
×
7012
                    \Sentry\captureException($e);
×
7013
                }
7014
            }
7015

7016
            # Check whether this user now counts as a possible spammer.
7017
            $s = new Spam($this->dbhr, $this->dbhm);
6✔
7018
            $s->checkUser($userid, $groupid);
6✔
7019

7020
            # We might have mod notes which require this member to be flagged up.
7021
            $comments = $this->dbhr->preQuery(
6✔
7022
                "SELECT COUNT(*) AS count FROM users_comments WHERE userid = ? AND flag = 1;", [
6✔
7023
                    $userid,
6✔
7024
                ]
6✔
7025
            );
6✔
7026

7027
            if ($comments[0]['count'] > 0) {
6✔
7028
                $this->memberReview($groupid, TRUE, 'Note flagged to other groups');
1✔
7029
            }
7030

7031
            $this->dbhm->preExec("UPDATE memberships_history SET processingrequired = 0 WHERE id = ?", [
6✔
7032
                $id
6✔
7033
            ]);
6✔
7034
        }
7035
    }
7036

7037
    public function getUserKey($id) {
7038
        $key = null;
99✔
7039
        $sql = "SELECT * FROM users_logins WHERE userid = ? AND type = ?;";
99✔
7040
        $logins = $this->dbhr->preQuery($sql, [$id, User::LOGIN_LINK]);
99✔
7041
        foreach ($logins as $login) {
99✔
7042
            $key = $login['credentials'];
31✔
7043
        }
7044

7045
        if (!$key) {
99✔
7046
            $key = Utils::randstr(32);
98✔
7047
            $rc = $this->dbhm->preExec("INSERT INTO users_logins (userid, type, credentials) VALUES (?,?,?);", [
98✔
7048
                $id,
98✔
7049
                User::LOGIN_LINK,
98✔
7050
                $key
98✔
7051
            ]);
98✔
7052
        }
7053

7054
        return $key;
98✔
7055
    }
7056

7057
    public function assignUserToToDonation($email, $userid) {
7058
        $email = trim($email);
540✔
7059

7060
        if (strlen($email)) {
540✔
7061
            # We might have donations made via PayPal using this email address which we can now link to this user.  Do
7062
            # SELECT first to avoid this having to replicate in the cluster.
7063
            $donations = $this->dbhr->preQuery("SELECT id FROM users_donations WHERE Payer = ? AND userid IS NULL;", [
540✔
7064
                $email
540✔
7065
            ]);
540✔
7066

7067
            foreach ($donations as $donation) {
540✔
7068
                // Check if user exists before updating to avoid foreign key constraint violations
7069
                $userExists = $this->dbhr->preQuery("SELECT id FROM users WHERE id = ?;", [$userid]);
46✔
7070
                if (count($userExists) > 0) {
46✔
7071
                    $this->dbhm->preExec("UPDATE users_donations SET userid = ? WHERE id = ?;", [
46✔
7072
                        $userid,
46✔
7073
                        $donation['id']
46✔
7074
                    ]);
46✔
7075
                }
7076
            }
7077
        }
7078
    }
7079
}
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