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

Freegle / iznik-server / #2575

21 Jan 2026 09:33PM UTC coverage: 85.913% (-0.02%) from 85.933%
#2575

push

edwh
fix: Exclude noreply@ilovefreegle.org from chat review email detection

The chat review system was incorrectly flagging messages containing
noreply@ilovefreegle.org as suspicious. This is a system address that
appears in automated emails and should not trigger review.

The issue was that Mail::ourDomain() only checks OURDOMAINS which
includes subdomains (users.ilovefreegle.org, groups.ilovefreegle.org)
but not the base domain (ilovefreegle.org).

Added specific exclusion for noreply@ addresses on ilovefreegle.org
while ensuring noreply@external.com addresses still trigger review.

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

6 of 6 new or added lines in 1 file covered. (100.0%)

53 existing lines in 3 files now uncovered.

25523 of 29708 relevant lines covered (85.91%)

30.6 hits per line

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

85.53
/include/spam/Spam.php
1
<?php
2
namespace Freegle\Iznik;
3

4

5
require_once(IZNIK_BASE . '/lib/GreatCircle.php');
74✔
6

7
use GeoIp2\Database\Reader;
8
use LanguageDetection\Language;
9

10
class Spam {
11
    const TYPE_SPAMMER = 'Spammer';
12
    const TYPE_WHITELIST = 'Whitelisted';
13
    const TYPE_PENDING_ADD = 'PendingAdd';
14
    const TYPE_PENDING_REMOVE = 'PendingRemove';
15
    const SPAM = 'Spam';
16
    const HAM = 'Ham';
17
    const URL_REMOVED = '(URL removed)';
18

19
    const USER_THRESHOLD = 5;
20
    const GROUP_THRESHOLD = 20;
21
    const SUBJECT_THRESHOLD = 30;  // SUBJECT_THRESHOLD must be > GROUP_THRESHOLD for UT
22
    const IMAGE_THRESHOLD = 5;
23
    const IMAGE_THRESHOLD_TIME = 24;
24

25
    # For checking users as suspect.
26
    const SEEN_THRESHOLD = 16; // Number of groups to join or apply to before considered suspect
27
    const ESCALATE_THRESHOLD = 2; // Level of suspicion before a user is escalated to support/admin for review
28
    const DISTANCE_THRESHOLD = 100; // Replies to items further apart than this is suspicious.  In miles.
29

30
    const REASON_NOT_SPAM = 'NotSpam';
31
    const REASON_COUNTRY_BLOCKED = 'CountryBlocked';
32
    const REASON_IP_USED_FOR_DIFFERENT_USERS = 'IPUsedForDifferentUsers';
33
    const REASON_IP_USED_FOR_DIFFERENT_GROUPS = 'IPUsedForDifferentGroups';
34
    const REASON_SUBJECT_USED_FOR_DIFFERENT_GROUPS = 'SubjectUsedForDifferentGroups';
35
    const REASON_SPAMASSASSIN = 'SpamAssassin';
36
    const REASON_GREETING = 'Greetings spam';
37
    const REASON_REFERRED_TO_SPAMMER = 'Referenced known spammer';
38
    const REASON_KNOWN_KEYWORD = 'Known spam keyword';
39
    const REASON_DBL = 'URL on DBL';
40
    const REASON_BULK_VOLUNTEER_MAIL = 'BulkVolunteerMail';
41
    const REASON_USED_OUR_DOMAIN = 'UsedOurDomain';
42
    const REASON_WORRY_WORD = 'WorryWord';
43
    const REASON_SCRIPT = 'Script';
44
    const REASON_LINK = 'Link';
45
    const REASON_MONEY = 'Money';
46
    const REASON_EMAIL = 'Email';
47
    const REASON_LANGUAGE = 'Language';
48
    const REASON_IMAGE_SENT_MANY_TIMES = 'SameImage';
49

50
    const ACTION_SPAM = 'Spam';
51
    const ACTION_REVIEW = 'Review';
52

53
    # A common type of spam involves two lines with greetings.
54
    private $greetings = [
55
        'hello', 'salutations', 'hey', 'good morning', 'sup', 'hi', 'good evening', 'good afternoon', 'greetings'
56
    ];
57

58
    /** @var  $dbhr LoggedPDO */
59
    private $dbhr;
60

61
    /** @var  $dbhm LoggedPDO */
62
    private $dbhm;
63
    private $reader = NULL;
64

65
    private $spamwords = NULL;
66

67
    function __construct($dbhr, $dbhm)
68
    {
69
        $this->dbhr = $dbhr;
470✔
70
        $this->dbhm = $dbhm;
470✔
71

72
        try {
73
            // This may fail in some test environments.
74
            $this->reader = new Reader(MMDB);
470✔
75
        } catch (\Exception $e) {}
×
76

77
        $this->log = new Log($this->dbhr, $this->dbhm);
470✔
78
    }
79

80
    public function checkMessage(Message $msg) {
81
        $ip = $msg->getFromIP();
199✔
82
        $host = NULL;
199✔
83

84
        // TN sends the IP in hashed form, so we can still do checks on use of the IP, but not look it up.
85
        $fromTN = $msg->getEnvelopefrom() == 'noreply@trashnothing.com' || strpos($msg->getEnvelopefrom(), '@user.trashnothing.com') !== FALSE;
199✔
86

87
        if (stripos($msg->getFromname(), GROUP_DOMAIN) !== FALSE || stripos($msg->getFromname(), USER_DOMAIN) !== FALSE) {
199✔
88
            # A domain which embeds one of ours in an attempt to fool us into thinking it is legit.
89
            return [ TRUE, Spam::REASON_USED_OUR_DOMAIN, "Used our domain inside from name " . $msg->getFromname() ] ;
2✔
90
        }
91

92
        if ($ip && !$fromTN) {
198✔
93
            if (strpos($ip, "10.") === 0) {
147✔
94
                # We've picked up an internal IP, ignore it.
95
                $ip = NULL;
5✔
96
            } else {
97
                $host = $msg->getFromhost();
143✔
98
                if ($host && preg_match('/mail.*yahoo\.com/', $host)) {
143✔
99
                    # Posts submitted by email to Yahoo show up with an X-Originating-IP of one of Yahoo's MTAs.  We don't
100
                    # want to consider those as spammers.
101
                    $ip = NULL;
×
102
                    $msg->setFromIP($ip);
×
103
                } else {
104
                    # Check if it's whitelisted
105
                    $sql = "SELECT * FROM spam_whitelist_ips WHERE ip = ?;";
143✔
106
                    $ips = $this->dbhr->preQuery($sql, [$ip]);
143✔
107
                    foreach ($ips as $wip) {
143✔
108
                        $ip = NULL;
26✔
109
                        $msg->setFromIP($ip);
26✔
110
                    }
111
                }
112
            }
113
        }
114

115
        if ($ip && $this->reader) {
198✔
116
            if (!$fromTN) {
117✔
117
                # We have an IP, we reckon.  It's unlikely that someone would fake an IP which gave a spammer match, so
118
                # we don't have to worry too much about FALSE positives.
119
                try {
120
                    $record = $this->reader->country($ip);
117✔
121
                    $country = $record->country->name;
116✔
122
                    $msg->setPrivate('fromcountry', $record->country->isoCode);
116✔
123
                } catch (\Exception $e) {
1✔
124
                    # Failed to look it up.
125
                    error_log("Failed to look up $ip " . $e->getMessage());
1✔
126
                    $country = NULL;
1✔
127
                }
128

129
                # Now see if we're blocking all mails from that country.  This is legitimate if our service is for a
130
                # single country and we are vanishingly unlikely to get legitimate emails from certain others.
131
                $countries = $this->dbhr->preQuery("SELECT * FROM spam_countries WHERE country = ?;", [$country]);
117✔
132
                foreach ($countries as $country) {
117✔
133
                    # Gotcha.
134
                    return(array(TRUE, Spam::REASON_COUNTRY_BLOCKED, "Blocking IP $ip as it's in {$country['country']}"));
1✔
135
                }
136
            }
137

138
            # Now see if this IP has been used for too many different users.  That is likely to
139
            # be someone masquerading to fool people.
140
            $sql = "SELECT fromname FROM messages_history WHERE fromip = ? AND groupid IS NOT NULL GROUP BY fromuser ORDER BY arrival DESC;";
116✔
141
            $users = $this->dbhr->preQuery($sql, [$ip]);
116✔
142
            $numusers = count($users);
116✔
143

144
            if ($numusers > Spam::USER_THRESHOLD) {
116✔
145
                $list = [];
1✔
146
                foreach ($users as $user) {
1✔
147
                    $list[] = $user['fromname'];
1✔
148
                }
149
                return(array(TRUE, Spam::REASON_IP_USED_FOR_DIFFERENT_USERS, "IP $ip " . ($host ? "($host)" : "") . " recently used for $numusers different users (" . implode(', ', $list) . ")"));
1✔
150
            }
151

152
            # Now see if this IP has been used for too many different groups.  That's likely to
153
            # be someone spamming.
154
            $sql = "SELECT groups.nameshort FROM messages_history INNER JOIN `groups` ON groups.id = messages_history.groupid WHERE fromip = ? GROUP BY groupid;";
116✔
155
            $groups = $this->dbhr->preQuery($sql, [$ip]);
116✔
156
            $numgroups = count($groups);
116✔
157

158
            if ($numgroups >= Spam::GROUP_THRESHOLD) {
116✔
159
                $list = [];
1✔
160
                foreach ($groups as $group) {
1✔
161
                    $list[] = $group['nameshort'];
1✔
162
                }
163
                return(array(TRUE, Spam::REASON_IP_USED_FOR_DIFFERENT_GROUPS, "IP $ip ($host) recently used for $numgroups different groups (" . implode(', ', $list) . ")"));
1✔
164
            }
165
        }
166

167
        # Now check whether this subject (pace any location) is appearing on many groups.
168
        #
169
        # Don't check very short subjects - might be something like "TAKEN".
170
        $subj = $msg->getPrunedSubject();
197✔
171

172
        if (strlen($subj) >= 10) {
197✔
173
            $sql = "SELECT COUNT(DISTINCT groupid) AS count FROM messages_history WHERE prunedsubject LIKE ? AND groupid IS NOT NULL;";
169✔
174
            $counts = $this->dbhr->preQuery($sql, [
169✔
175
                "$subj%"
169✔
176
            ]);
169✔
177

178
            foreach ($counts as $count) {
169✔
179
                if ($count['count'] >= Spam::SUBJECT_THRESHOLD) {
169✔
180
                    # Possible spam subject - but check against our whitelist.
181
                    $found = FALSE;
2✔
182
                    $sql = "SELECT id FROM spam_whitelist_subjects WHERE subject = ?;";
2✔
183
                    $whites = $this->dbhr->preQuery($sql, [$subj]);
2✔
184
                    foreach ($whites as $white) {
2✔
185
                        $found = TRUE;
2✔
186
                    }
187

188
                    if (!$found) {
2✔
189
                        return (array(TRUE, Spam::REASON_SUBJECT_USED_FOR_DIFFERENT_GROUPS, "Warning - subject $subj recently used on {$count['count']} groups"));
2✔
190
                    }
191
                }
192
            }
193
        }
194

195
        # Now check if this sender has mailed a lot of owners recently.
196
        $sql = "SELECT COUNT(*) AS count FROM messages WHERE envelopefrom = ? and envelopeto LIKE '%-volunteers@" . GROUP_DOMAIN . "' AND arrival >= '" . date("Y-m-d H:i:s", strtotime("24 hours ago")) . "'";
197✔
197
        $counts = $this->dbhr->preQuery($sql, [
197✔
198
            $msg->getEnvelopefrom()
197✔
199
        ]);
197✔
200

201
        foreach ($counts as $count) {
197✔
202
            if ($count['count'] >= Spam::GROUP_THRESHOLD) {
197✔
203
                return (array(TRUE, Spam::REASON_BULK_VOLUNTEER_MAIL, "Warning - " . $msg->getEnvelopefrom() . " mailed {$count['count']} group volunteer addresses recently"));
1✔
204
            }
205
        }
206

207
        # Now check if this subject line has been used in mails to lots of owners recently.
208
        $sql = "SELECT COUNT(*) AS count FROM messages WHERE subject LIKE ? and envelopeto LIKE '%-volunteers@" . GROUP_DOMAIN . "' AND arrival >= '" . date("Y-m-d H:i:s", strtotime("24 hours ago")) . "'";
197✔
209
        $counts = $this->dbhr->preQuery($sql, [
197✔
210
            $msg->getSubject()
197✔
211
        ]);
197✔
212

213
        foreach ($counts as $count) {
197✔
214
            if ($count['count'] >= Spam::GROUP_THRESHOLD) {
197✔
215
//                mail("log@ehibbert.org.uk", "Spam subject " . $msg->getSubject(), "Warning - subject " . $msg->getSubject() . " mailed to {$count['count']} group volunteer addresses recently", [], '-fnoreply@modtools.org');
216
                return (array(TRUE, Spam::REASON_BULK_VOLUNTEER_MAIL, "Warning - subject " . $msg->getSubject() . " mailed to {$count['count']} group volunteer addresses recently"));
1✔
217
            }
218
        }
219

220
        # Get the text to scan.  No point in scanning any text we would strip before passing it on.
221
        $text = $msg->stripQuoted();
197✔
222

223
        # Check if this is a greetings spam.
224
        if (stripos($text, 'http') || stripos($text, '.php')) {
197✔
225
            $p = strpos($text, "\n");
3✔
226
            $q = strpos($text, "\n", $p + 1);
3✔
227
            $r = strpos($text, "\n", $q + 1);
3✔
228

229
            $line1 = $p ? substr($text, 0, $p) : '';
3✔
230
            $line3 = $q ? substr($text, $q + 1, $r) : '';
3✔
231

232
            $line1greeting = FALSE;
3✔
233
            $line3greeting = FALSE;
3✔
234
            $subjgreeting = FALSE;
3✔
235

236
            foreach ($this->greetings as $greeting) {
3✔
237
                if (stripos($subj, $greeting) === 0) {
3✔
238
                    $subjgreeting = TRUE;
1✔
239
                }
240

241
                if (stripos($line1, $greeting) === 0) {
3✔
242
                    $line1greeting = TRUE;
1✔
243
                }
244

245
                if (stripos($line3, $greeting) === 0) {
3✔
246
                    $line3greeting = TRUE;
1✔
247
                }
248
            }
249

250
            if ($subjgreeting && $line1greeting || $line1greeting && $line3greeting) {
3✔
251
                return (array(TRUE, Spam::REASON_GREETING, "Message looks like a greetings spam"));
1✔
252
            }
253
        }
254

255
        $spammail = $this->checkReferToSpammer($text);
196✔
256

257
        if ($spammail) {
196✔
258
            return (array(TRUE, Spam::REASON_REFERRED_TO_SPAMMER, "Refers to known spammer $spammail"));
1✔
259
        }
260

261
        # Don't block spam from ourselves.
262
        if ($msg->getEnvelopefrom() != SUPPORT_ADDR && $msg->getEnvelopefrom() != INFO_ADDR) {
195✔
263
            # For messages we want to spot any dubious items.
264
            $r = $this->checkSpam($text, [ Spam::ACTION_REVIEW, Spam::ACTION_SPAM ]);
195✔
265
            if ($r) {
195✔
266
                return ($r);
3✔
267
            }
268

269
            $r = $this->checkSpam($subj, [ Spam::ACTION_REVIEW, Spam::ACTION_SPAM ]);
194✔
270
            if ($r) {
194✔
271
                return ($r);
1✔
272
            }
273
        }
274

275
        # It's fine.  So far as we know.
276
        return(NULL);
194✔
277
    }
278

279
    private function getSpamWords() {
280
        if (!$this->spamwords) {
220✔
281
            $this->spamwords = $this->dbhr->preQuery("SELECT * FROM spam_keywords;");
220✔
282
        }
283
    }
284

285
    public function checkReview($message, $language) {
286
        # Spammer trick is to encode the dot in URLs.
287
        $message = str_replace('&#12290;', '.', $message);
53✔
288

289
        #error_log("Check review $message len " . strlen($message));
290
        if (strlen($message) == 0) {
53✔
291
            # Blank is odd, but not spam.
292
            return NULL;
×
293
        }
294

295
        $check = NULL;
53✔
296

297
        if (!$check && stripos($message, '<script') !== FALSE) {
53✔
298
            # Looks dodgy.
299
            $check = self::REASON_SCRIPT;
1✔
300
        }
301

302
        if (!$check) {
53✔
303
            # Check for URLs.
304
            if (stripos($message,Spam::URL_REMOVED) !== FALSE) {
53✔
305
                # A URL which has been removed.
306
                $check = self::REASON_LINK;
4✔
307
            } else if (preg_match_all(Utils::URL_PATTERN, $message, $matches)) {
49✔
308
                # A link.  Some domains are ok - where they have been whitelisted several times (to reduce bad whitelists).
309
                $ourdomains = $this->dbhr->preQuery("SELECT domain FROM spam_whitelist_links WHERE count >= 3 AND LENGTH(domain) > 5 AND domain NOT LIKE '%linkedin%' AND domain NOT LIKE '%goo.gl%' AND domain NOT LIKE '%bit.ly%' AND domain NOT LIKE '%tinyurl%';");
4✔
310

311
                $valid = 0;
4✔
312
                $count = 0;
4✔
313
                $badurl = NULL;
4✔
314

315
                foreach ($matches as $val) {
4✔
316
                    foreach ($val as $url) {
4✔
317
                        $bad = FALSE;
4✔
318
                        $url2 = str_replace('http:', '', $url);
4✔
319
                        $url2 = str_replace('https:', '', $url2);
4✔
320
                        foreach (Utils::URL_BAD as $badone) {
4✔
321
                            if (strpos($url2, $badone) !== FALSE) {
4✔
322
                                $bad = TRUE;
×
323
                            }
324
                        }
325

326
                        if (!$bad && strlen($url) > 0) {
4✔
327
                            $url = substr($url, strpos($url, '://') + 3);
4✔
328
                            $count++;
4✔
329
                            $trusted = FALSE;
4✔
330

331
                            foreach ($ourdomains as $domain) {
4✔
332
                                if (stripos($url, $domain['domain']) === 0) {
4✔
333
                                    # One of our domains.
334
                                    $valid++;
1✔
335
                                    $trusted = TRUE;
1✔
336
                                }
337
                            }
338

339
                            $badurl = $trusted ? $badurl : $url;
4✔
340
                        }
341
                    }
342
                }
343

344
                if ($valid < $count) {
4✔
345
                    # At least one URL which we don't trust.
346
                    $check = self::REASON_LINK;
4✔
347
                }
348
            }
349
        }
350

351
        if (!$check) {
53✔
352
            # Check keywords
353
            $this->getSpamWords();
47✔
354
            foreach ($this->spamwords as $word) {
47✔
355
                $w = $word['type'] == 'Literal' ? preg_quote($word['word']) : $word['word'];
47✔
356

357
                if ($word['action'] == 'Review' &&
47✔
358
                    preg_match('/\b' . $w . '\b/i', $message) &&
47✔
359
                    (!$word['exclude'] || !preg_match('/' . $word['exclude'] . '/i', $message))) {
47✔
360
                    $check = self::REASON_KNOWN_KEYWORD;
1✔
361
                }
362
            }
363
        }
364

365
        if (!$check && (strpos($message, '$') !== FALSE || strpos($message, '£') !== FALSE || strpos($message, '(a)') !== FALSE)) {
53✔
366
            $check = self::REASON_MONEY;
2✔
367
        }
368

369
        # Email addresses are suspect too; a scammer technique is to take the conversation offlist.
370
        if (!$check && preg_match_all(Message::EMAIL_REGEXP, $message, $matches)) {
53✔
371
            foreach ($matches as $val) {
4✔
372
                foreach ($val as $email) {
4✔
373
                    # Exclude our domains, partner domains, and our noreply addresses
374
                    $isNoreplyOnOurDomain = stripos($email, 'noreply@') === 0 &&
4✔
375
                        stripos($email, 'ilovefreegle.org') !== FALSE;
4✔
376

377
                    if (!Mail::ourDomain($email) &&
4✔
378
                        strpos($email, 'trashnothing') === FALSE &&
4✔
379
                        strpos($email, 'yahoogroups') === FALSE &&
4✔
380
                        !$isNoreplyOnOurDomain) {
4✔
381
                        $check = self::REASON_EMAIL;
3✔
382
                    }
383
                }
384
            }
385
        }
386

387
        if (!$check && $this->checkReferToSpammer($message)) {
53✔
388
            $check = self::REASON_REFERRED_TO_SPAMMER;
1✔
389
        }
390

391
        if (!$check && $language) {
53✔
392
            # Check language is English.  This isn't out of some kind of misplaced nationalistic fervour, but just
393
            # because our spam filters work less well on e.g. French.
394
            #
395
            # Short strings like 'test' or 'ok thanks' or 'Eileen', don't always come out as English, so only check
396
            # slightly longer messages where the identification is more likely to work.
397
            #
398
            # We check that English is the most likely, or fairly likely compared to the one chosen.
399
            #
400
            # This is a fairly lax test but spots text which is very probably in another language.
401
            $message = str_ireplace('xxx', '', strtolower(trim($message)));
43✔
402

403
            if (strlen($message) > 50) {
43✔
404
                $ld = new Language;
12✔
405
                $lang = $ld->detect($message)->close();
12✔
406
                reset($lang);
12✔
407
                $firstlang = key($lang);
12✔
408
                $firstprob = Utils::presdef($firstlang, $lang, 0);
12✔
409
                $enprob = Utils::presdef('en', $lang, 0);
12✔
410
                $cyprob = Utils::presdef('cy', $lang, 0);
12✔
411
                $ourprob = max($enprob, $cyprob);
12✔
412

413
                $check = !($firstlang == 'en' || $firstlang == 'cy' || $ourprob >= 0.8 * $firstprob);
12✔
414

415
                if ($check) {
12✔
416
                    $check = self::REASON_LANGUAGE;
1✔
417
                }
418
            }
419
        }
420

421
        return($check);
53✔
422
    }
423

424
    public function checkSpam($message, $actions) {
425
        $ret = NULL;
217✔
426

427
        # Strip out any job text, which might have spam keywords.
428
        $message = preg_replace('/\<https\:\/\/www\.ilovefreegle\.org\/jobs\/.*\>.*$/im', '', $message);
217✔
429

430
        # Some spammers use HTML entities in text bodyparts to disguise words.
431
        $message = str_replace('&#616;', 'i', $message);
217✔
432
        $message = str_replace('&#537;', 's', $message);
217✔
433
        $message = str_replace('&#206;', 'I', $message);
217✔
434
        $message = str_replace('=C2', '£', $message);
217✔
435

436
        # Check keywords which are known as spam.
437
        $this->getSpamWords();
217✔
438
        foreach ($this->spamwords as $word) {
217✔
439
            if (strlen(trim($word['word'])) > 0) {
217✔
440
                $exp = '/\b' . preg_quote($word['word']) . '\b/i';
217✔
441
                if (in_array($word['action'], $actions) &&
217✔
442
                    preg_match($exp, $message) &&
217✔
443
                    (!$word['exclude'] || !preg_match('/' . $word['exclude'] . '/i', $message))) {
217✔
444
                    $ret = array(TRUE, Spam::REASON_KNOWN_KEYWORD, "Refers to keyword '{$word['word']}'");
6✔
445
                }
446
            }
447
        }
448

449
        # Check whether any URLs are in Spamhaus DBL black list.
450
        if (preg_match_all(Utils::URL_PATTERN, $message, $matches)) {
217✔
451
            $checked = [];
5✔
452

453
            foreach ($matches as $val) {
5✔
454
                foreach ($val as $url) {
5✔
455
                    $bad = FALSE;
5✔
456
                    $url2 = str_replace('http:', '', $url);
5✔
457
                    $url2 = str_replace('https:', '', $url2);
5✔
458
                    foreach (Utils::URL_BAD as $badone) {
5✔
459
                        if (strpos($url2, $badone) !== FALSE) {
5✔
UNCOV
460
                            $bad = TRUE;
×
461
                        }
462
                    }
463

464
                    if (!$bad && strlen($url) > 0) {
5✔
465
                        $url = substr($url, strpos($url, '://') + 3);
5✔
466

467
                        if (array_key_exists($url, $checked)) {
5✔
468
                            # We do this part for performance and part because we've seen hangs in dns_get_record
469
                            # when checking Spamhaus repeatedly in UT.
UNCOV
470
                            $ret = $checked[$url];
×
471
                        }
472

473
                        if (Mail::checkSpamhaus("http://$url")) {
5✔
UNCOV
474
                            $ret = [ TRUE, Spam::REASON_DBL, "Blacklisted url $url" ];
×
UNCOV
475
                            $checked[$url] = $ret;
×
476
                        }
477

478
                        if (preg_match('/.+' . GROUP_DOMAIN . '/', $url) || preg_match('/.+' . USER_DOMAIN . '/', $url)) {
5✔
479
                            # A domain which embeds one of ours in an attempt to fool us into thinking it is legit.
UNCOV
480
                            $ret = [ TRUE, Spam::REASON_USED_OUR_DOMAIN, "Used our domain inside $url" ] ;
×
481
                            $checked[$url] = $ret;
×
482
                        }
483
                    }
484
                }
485
            }
486
        }
487

488
        return($ret);
217✔
489
    }
490

491
    public function checkReferToSpammer($text) {
492
        $ret = NULL;
255✔
493

494
        if (strpos($text, '@') !== FALSE) {
255✔
495
            # Check if it contains a reference to a known spammer.
496
            if (preg_match_all(Message::EMAIL_REGEXP, $text, $matches)) {
5✔
497
                foreach ($matches as $val) {
5✔
498
                    foreach ($val as $email) {
5✔
499
                        $spammers = $this->dbhr->preQuery("SELECT users_emails.email FROM spam_users INNER JOIN users_emails ON spam_users.userid = users_emails.userid WHERE collection = ? AND email LIKE ?;", [
5✔
500
                            Spam::TYPE_SPAMMER,
5✔
501
                            $email
5✔
502
                        ]);
5✔
503

504
                        $ret = count($spammers) > 0 ? $spammers[0]['email'] : NULL;
5✔
505

506
                        if ($ret) {
5✔
507
                            break;
2✔
508
                        }
509
                    }
510
                }
511
            }
512
        }
513

514
        return($ret);
255✔
515
    }
516

517
    public function notSpamSubject($subj) {
518
        $sql = "INSERT IGNORE INTO spam_whitelist_subjects (subject, comment) VALUES (?, 'Marked as not spam');";
1✔
519
        $this->dbhm->preExec($sql, [ $subj ]);
1✔
520
    }
521

522
    public function checkUser($userid, $groupJustAdded, $lat = NULL, $lng = NULL, $checkmemberships = TRUE) {
523
        # Called when something has happened to a user which makes them more likely to be a spammer, and therefore
524
        # needs rechecking.
525
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
27✔
526

527
        $u = User::get($this->dbhr, $this->dbhm, $userid);
27✔
528

529
        if ($u->getId() == $userid && $u->isModerator()) {
27✔
530
            # We whitelist all mods.
531
            return FALSE;
3✔
532
        }
533

534
        $suspect = FALSE;
26✔
535
        $reason = NULL;
26✔
536
        $suspectgroups = [];
26✔
537

538
        if ($checkmemberships) {
26✔
539
            # Check whether they have applied to a suspicious number of groups, but exclude whitelisted members.
540
            #
541
            # If we have just added a membership then it may not have been logged yet, so we might fail to count
542
            # it.  This happens in UT.
543
            $groupq = $groupJustAdded ? " AND logs.groupid != $groupJustAdded " : '';
5✔
544
            $start = date('Y-m-d', strtotime("365 days ago"));
5✔
545

546
            $sql = "SELECT COUNT(DISTINCT(groupid)) AS count FROM logs LEFT JOIN spam_users ON spam_users.userid = logs.user AND spam_users.collection = ? WHERE logs.user = ? AND logs.type = ? AND logs.subtype = ? AND spam_users.userid IS NULL $groupq AND logs.timestamp >= ?;";
5✔
547
            $counts = $this->dbhr->preQuery($sql, [
5✔
548
                Spam::TYPE_WHITELIST,
5✔
549
                $userid,
5✔
550
                Log::TYPE_GROUP,
5✔
551
                Log::SUBTYPE_JOINED,
5✔
552
                $start
5✔
553
            ]);
5✔
554

555
            $count = $counts[0]['count'];
5✔
556

557
            if ($groupJustAdded) {
5✔
558
                $count++;
5✔
559
            }
560

561
            if ($count > Spam::SEEN_THRESHOLD) {
5✔
562
                $suspect = TRUE;
2✔
563
                $reason = "Seen on many groups";
2✔
564
            }
565
        }
566

567
        if (!$suspect) {
26✔
568
            list($suspect, $reason, $suspectgroups) = $this->checkReplyDistance(
26✔
569
                $userid,
26✔
570
                $lat,
26✔
571
                $lng,
26✔
572
            );
26✔
573
        }
574

575
        if ($suspect) {
26✔
576
            # This user is suspect.  We will mark it as so, which means that it'll show up to mods on relevant groups,
577
            # and they will review it.
578
            $this->log->log([
3✔
579
                'type' => Log::TYPE_USER,
3✔
580
                'subtype' => Log::SUBTYPE_SUSPECT,
3✔
581
                'byuser' => $me ? $me->getId() : NULL,
3✔
582
                'user' => $userid,
3✔
583
                'text' => $reason
3✔
584
            ]);
3✔
585

586
            $memberships = $u->getMemberships();
3✔
587

588
            foreach ($memberships as $membership) {
3✔
589
                if (!count($suspectgroups) || in_array($membership['id'], $suspectgroups)) {
2✔
590
                    $u->memberReview($membership['id'], TRUE, $reason);
2✔
591
                }
592
            }
593

594
            User::clearCache($userid);
3✔
595
        }
596

597
        return $suspect;
26✔
598
    }
599

600
    public function collectionCounts() {
601
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
28✔
602

603
        $ret = [
28✔
604
            Spam::TYPE_PENDING_ADD => 0,
28✔
605
            Spam::TYPE_PENDING_REMOVE => 0
28✔
606
        ];
28✔
607

608
        if ($me && $me->hasPermission(User::PERM_SPAM_ADMIN)) {
28✔
609
            $sql = "SELECT COUNT(*) AS count, collection FROM spam_users WHERE collection IN (?, ?) GROUP BY collection;";
1✔
610
            $counts = $this->dbhr->preQuery(
1✔
611
                $sql,
1✔
612
                [
1✔
613
                    Spam::TYPE_PENDING_ADD,
1✔
614
                    Spam::TYPE_PENDING_REMOVE
1✔
615
                ]
1✔
616
            );
1✔
617

618
            foreach ($counts as $count) {
1✔
619
                $ret[$count['collection']] = $count['count'];
1✔
620
            }
621
        }
622

623
        return($ret);
28✔
624
    }
625

626
    public function exportSpammers() {
627
        $sql = "SELECT spam_users.id, spam_users.added, reason, email FROM spam_users INNER JOIN users_emails ON spam_users.userid = users_emails.userid WHERE collection = ?;";
1✔
628
        $spammers = $this->dbhr->preQuery($sql, [ Spam::TYPE_SPAMMER ]);
1✔
629
        return($spammers);
1✔
630
    }
631

632
    public function listSpammers($collection, $search, &$context) {
633
        # We exclude anyone who isn't a User (e.g. mods, support, admin) so that they don't appear on the list and
634
        # get banned.
635
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
2✔
636
        $seeall = $me && $me->isAdminOrSupport();
2✔
637
        $collectionq = ($collection ? " AND collection = '$collection'" : '');
2✔
638
        $startq = $context ? (" AND spam_users.id <  " . intval($context['id']) . " ") : '';
2✔
639
        $searchq = is_null($search) ? '' : (" AND (users_emails.email LIKE " . $this->dbhr->quote("%$search%") . " OR users.fullname LIKE " . $this->dbhr->quote("%$search%") . ") ");
2✔
640
        $sql = "SELECT DISTINCT spam_users.* FROM spam_users INNER JOIN users ON spam_users.userid = users.id LEFT JOIN users_emails ON users_emails.userid = spam_users.userid WHERE 1=1 $startq $collectionq $searchq ORDER BY spam_users.id DESC LIMIT 10;";
2✔
641
        $context = [];
2✔
642

643
        $spammers = $this->dbhr->preQuery($sql);
2✔
644
        $u = new User($this->dbhr, $this->dbhm);
2✔
645
        $spammeruids = array_filter(array_unique(array_column($spammers, 'userid')));
2✔
646
        $users = $u->getPublicsById($spammeruids, NULL, TRUE, $seeall);
2✔
647
        $ctx = NULL;
2✔
648
        $moduids = array_filter(array_unique(array_merge(array_column($spammers, 'byuserid'), array_column($spammers, 'heldby'))));
2✔
649
        $users2 = $u->getPublicsById($moduids, NULL, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE);
2✔
650
        $users = array_replace($users, $users2);
2✔
651
        $emails = $u->getEmailsById(array_merge($spammeruids, $moduids));
2✔
652

653
        foreach ($spammers as &$spammer) {
2✔
654
            $spammer['user'] = $users[$spammer['userid']];
1✔
655

656
            $es = Utils::presdef($spammer['userid'], $emails, []);
1✔
657

658
            foreach ($es as $anemail) {
1✔
659
                if (!Utils::pres('email', $spammer['user']) && !Mail::ourDomain($anemail['email']) && strpos($anemail['email'], '@yahoogroups.') === FALSE) {
1✔
660
                    $spammer['user']['email'] = $anemail['email'];
1✔
661
                }
662
            }
663

664
            $others = [];
1✔
665

666
            foreach ($es as $anemail) {
1✔
667
                if ($anemail['email'] != $spammer['user']['email']) {
1✔
668
                    $others[] = $anemail;
1✔
669
                }
670
            }
671

672
            uasort($others, function($a, $b) {
1✔
673
                return(strcmp($a['email'], $b['email']));
1✔
674
            });
1✔
675

676
            $spammer['user']['otheremails'] = $others;
1✔
677

678
            if ($spammer['byuserid']) {
1✔
679
                $spammer['byuser'] = $users[$spammer['byuserid']];
1✔
680

681
                if ($me->isModerator()) {
1✔
682
                    $es = Utils::presdef($spammer['byuserid'], $emails, []);
1✔
683

684
                    foreach ($es as $anemail) {
1✔
685
                        if ($anemail['email'] != $spammer['user']['email']) {
1✔
686
                            $others[] = $anemail;
1✔
687
                        }
688

689
                        if (!Utils::pres('email', $spammer['byuser']) && !Mail::ourDomain($anemail['email']) && strpos($anemail['email'], '@yahoogroups.') === FALSE) {
1✔
UNCOV
690
                            $spammer['byuser']['email'] = $anemail['email'];
×
691
                        }
692
                    }
693
                }
694
            }
695

696
            if ($collection ==  Spam::TYPE_PENDING_ADD) {
1✔
697
                if (Utils::pres('heldby', $spammer)) {
1✔
698
                    $spammer['user']['heldby'] = $users[$spammer['heldby']];
1✔
699
                    $spammer['user']['heldat'] = Utils::ISODate($spammer['heldat']);
1✔
700
                    unset($spammer['heldby']);
1✔
701
                    unset($spammer['heldat']);
1✔
702
                }
703
            }
704

705
            $spammer['added'] = Utils::ISODate($spammer['added']);
1✔
706

707
            # Add in any other users who have recently used the same IP.  But not for TN users, because
708
            # the TN servers use our API for multiple users.
709
            $spammer['sameip'] = [];
1✔
710

711
            if (strpos($spammer['user']['email'], '@user.trashnothing.com') === FALSE) {
1✔
712
                $loki = Loki::getInstance();
1✔
713
                $ips = $loki->findUserIPs($spammer['userid']);
1✔
714

715
                foreach ($ips as $ip) {
1✔
UNCOV
716
                    $otherusers = $loki->findUsersForIP($ip);
×
717

UNCOV
718
                    foreach ($otherusers as $otherUserId) {
×
UNCOV
719
                        if ($otherUserId != $spammer['userid']) {
×
UNCOV
720
                            $spammer['sameip'][] = $otherUserId;
×
721
                        }
722
                    }
723
                }
724

725
                $spammer['sameip'] = array_unique($spammer['sameip']);
1✔
726
            }
727

728
            $context['id'] = $spammer['id'];
1✔
729
        }
730

731
        return($spammers);
2✔
732
    }
733

734
    public function getSpammer($id) {
735
        $sql = "SELECT * FROM spam_users WHERE id = ?;";
1✔
736
        $ret = NULL;
1✔
737

738
        $spams = $this->dbhr->preQuery($sql, [ $id ]);
1✔
739

740
        foreach ($spams as $spam) {
1✔
741
            $ret = $spam;
1✔
742
        }
743

744
        return($ret);
1✔
745
    }
746

747
    public function getSpammerByUserid($userid, $collection = Spam::TYPE_SPAMMER) {
748
        $sql = "SELECT * FROM spam_users WHERE userid = ? AND collection = ?;";
36✔
749
        $ret = NULL;
36✔
750

751
        $spams = $this->dbhr->preQuery($sql, [ $userid, $collection ]);
36✔
752

753
        foreach ($spams as $spam) {
36✔
754
            $ret = $spam;
1✔
755
        }
756

757
        return($ret);
36✔
758
    }
759

760
    public function removeSpamMembers($groupid = NULL) {
761
        $count = 0;
1✔
762
        $groupq = $groupid ? " AND groupid = $groupid " : "";
1✔
763

764
        # Find anyone in the spammer list with a current (approved or pending) membership.  Don't remove mods
765
        # in case someone wrongly gets onto the list.
766
        $sql = "SELECT * FROM memberships INNER JOIN spam_users ON memberships.userid = spam_users.userid AND spam_users.collection = ? AND memberships.role = 'Member' $groupq;";
1✔
767
        $spammers = $this->dbhr->preQuery($sql, [ Spam::TYPE_SPAMMER ]);
1✔
768

769
        foreach ($spammers as $spammer) {
1✔
770
            error_log("Found spammer {$spammer['userid']}");
1✔
771
            $u = User::get($this->dbhr, $this->dbhm, $spammer['userid']);
1✔
772
            error_log("Remove spammer {$spammer['userid']}");
1✔
773
            $u->removeMembership($spammer['groupid'], TRUE, TRUE);
1✔
774
            $count++;
1✔
775
        }
776

777
        # Find any messages from spammers which are on groups.
778
        $groupq = $groupid ? " AND messages_groups.groupid = $groupid " : "";
1✔
779
        $sql = "SELECT DISTINCT messages.id, reason, messages_groups.groupid FROM `messages` INNER JOIN spam_users ON messages.fromuser = spam_users.userid AND spam_users.collection = ? AND messages.deleted IS NULL INNER JOIN messages_groups ON messages.id = messages_groups.msgid INNER JOIN users ON messages.fromuser = users.id AND users.systemrole = 'User' $groupq;";
1✔
780
        $spammsgs = $this->dbhr->preQuery($sql, [
1✔
781
            Spam::TYPE_SPAMMER
1✔
782
        ]);
1✔
783

784
        foreach ($spammsgs as $spammsg) {
1✔
785
            error_log("Found spam message {$spammsg['id']}");
1✔
786
            $m = new Message($this->dbhr, $this->dbhm, $spammsg['id']);
1✔
787
            $m->delete("From known spammer {$spammsg['reason']}");
1✔
788
            $count++;
1✔
789
        }
790

791
        # Find any chat messages from spammers.
792
        $chats = $this->dbhr->preQuery("SELECT id, chatid FROM chat_messages WHERE 
1✔
793
userid IN (SELECT userid FROM spam_users WHERE collection = 'Spammer')
794
AND reviewrejected != 1;");
1✔
795
        foreach ($chats as $chat) {
1✔
UNCOV
796
            error_log("Found spam chat message {$chat['id']}");
×
UNCOV
797
            $sql = "UPDATE chat_messages SET reviewrejected = 1, reviewrequired = 0 WHERE id = ?";
×
UNCOV
798
            $this->dbhm->preExec($sql, [ $chat['id'] ]);
×
799
        }
800

801
        # Delete any newsfeed items from spammers.
802
        $newsfeeds = $this->dbhr->preQuery("SELECT id FROM newsfeed WHERE userid IN (SELECT userid FROM spam_users WHERE collection = 'Spammer');");
1✔
803
        foreach ($newsfeeds as $newsfeed) {
1✔
804
            error_log("Delete newsfeed item {$newsfeed['id']}");
×
805
            $sql = "DELETE FROM newsfeed WHERE id = ?;";
×
UNCOV
806
            $this->dbhm->preExec($sql, [ $newsfeed['id'] ]);
×
807
        }
808

809
        # Delete any notifications from spammers
810
        $notifs = $this->dbhr->preQuery("SELECT id FROM users_notifications WHERE fromuser IN (SELECT userid FROM spam_users WHERE collection = 'Spammer');");
1✔
811
        foreach ($notifs as $notif) {
1✔
812
            error_log("Delete notification {$notif['id']}");
×
813
            $sql = "DELETE FROM users_notifications WHERE id = ?;";
×
UNCOV
814
            $this->dbhm->preExec($sql, [ $notif['id'] ]);
×
815
        }
816

817
        # Remove any cases where the spammer has said they're waiting for a reply, which makes the spammee look
818
        # bad.
819
        $expecteds = $this->dbhr->preQuery("SELECT users_expected.id FROM `users_expected` INNER JOIN spam_users ON expecter = spam_users.userid AND collection = 'Spammer';");
1✔
820
        foreach ($expecteds as $expected) {
1✔
821
            error_log("Delete expected {$expected['id']}");
×
UNCOV
822
            $this->dbhm->preExec("DELETE FROM users_expected WHERE id = ?;", [
×
UNCOV
823
                $expected['id']
×
UNCOV
824
            ]);
×
825
        }
826

827
        # Delete any sessions for spammers.
828
        $sessions = $this->dbhr->preQuery("SELECT sessions.id, sessions.userid FROM sessions INNER JOIN 
1✔
829
    spam_users ON spam_users.userid = sessions.userid WHERE sessions.userid IS NOT NULL AND collection = ?;", [
1✔
830
            Spam::TYPE_SPAMMER
1✔
831
        ]);
1✔
832

833
        foreach ($sessions as $session) {
1✔
UNCOV
834
            error_log("Delete session {$session['id']} for {$session['userid']}");
×
UNCOV
835
            $this->dbhm->preExec("DELETE FROM sessions WHERE id = ?;", [
×
UNCOV
836
                $session['id']
×
UNCOV
837
            ]);
×
838
        }
839

840
        return($count);
1✔
841
    }
842

843
    public function addSpammer($userid, $collection, $reason) {
844
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
6✔
845
        $text = NULL;
6✔
846
        $id = NULL;
6✔
847

848
        switch ($collection) {
849
            case Spam::TYPE_WHITELIST: {
850
                $text = "Whitelisted: $reason";
1✔
851

852
                # Ensure nobody who is whitelisted is banned.
853
                $this->dbhm->preExec("DELETE FROM users_banned WHERE userid IN (SELECT userid FROM spam_users WHERE collection = ?);", [
1✔
854
                    Spam::TYPE_WHITELIST
1✔
855
                ]);
1✔
856
                break;
1✔
857
            }
858
            case Spam::TYPE_PENDING_ADD: {
859
                $text = "Reported: $reason";
2✔
860

861
                # We set the newsfeed status of any reported user to 'Suppressed', to reduce vandalism.
862
                $u = new User($this->dbhr, $this->dbhm, $userid);
2✔
863
                if ($u->getPrivate('systemrole') == User::SYSTEMROLE_USER) {
2✔
864
                    $u->setPrivate('newsfeedmodstatus', User::NEWSFEED_SUPPRESSED);
2✔
865
                }
866

867
                break;
2✔
868
            }
869
        }
870

871
        $this->log->log([
6✔
872
            'type' => Log::TYPE_USER,
6✔
873
            'subtype' => Log::SUBTYPE_SUSPECT,
6✔
874
            'byuser' => $me ? $me->getId() : NULL,
6✔
875
            'user' => $userid,
6✔
876
            'text' => $text
6✔
877
        ]);
6✔
878

879
        $proceed = TRUE;
6✔
880

881
        if ($collection == Spam::TYPE_PENDING_ADD) {
6✔
882
            # We don't want to overwrite an existing entry in the spammer list just because someone tries to
883
            # report it again.
884
            $u = new User($this->dbhr, $this->dbhm, $userid);
2✔
885

886
            $ourDomain = FALSE;
2✔
887

888
            foreach ($u->getEmails() as $email) {
2✔
889
                if (strpos($email['email'], USER_DOMAIN) === FALSE && $email['ourdomain']) {
2✔
890
                    # Don't report spammers on our own domains.  They will be spoofed.
891
                    $proceed = FALSE;
1✔
892
                }
893
            }
894

895
            if ($proceed) {
2✔
896
                $spammers = $this->dbhr->preQuery("SELECT * FROM spam_users WHERE userid = ?;", [ $userid ]);
1✔
897
                foreach ($spammers as $spammer) {
1✔
898
                    $proceed = FALSE;
1✔
899
                }
900
            }
901
        }
902

903
        if ($proceed) {
6✔
904
            $sql = "REPLACE INTO spam_users (userid, collection, reason, byuserid, heldby, heldat) VALUES (?,?,?,?, NULL, NULL);";
5✔
905
            $rc = $this->dbhm->preExec($sql, [
5✔
906
                $userid,
5✔
907
                $collection,
5✔
908
                $reason,
5✔
909
                $me ? $me->getId() : NULL
5✔
910
            ]);
5✔
911

912
            $id = $rc ? $this->dbhm->lastInsertId() : NULL;
5✔
913
        }
914

915
        return($id);
6✔
916
    }
917

918
    public function updateSpammer($id, $userid, $collection, $reason, $heldby) {
919
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
1✔
920

921
        switch ($collection) {
922
            case Spam::TYPE_SPAMMER: {
923
                $text = "Confirmed as spammer";
1✔
924
                break;
1✔
925
            }
926
            case Spam::TYPE_WHITELIST: {
927
                $text = "Whitelisted: $reason";
1✔
928
                break;
1✔
929
            }
930
            case Spam::TYPE_PENDING_ADD: {
931
                $text = "Reported: $reason";
1✔
932
                break;
1✔
933
            }
934
            case Spam::TYPE_PENDING_REMOVE: {
935
                $text = "Requested removal: $reason";
1✔
936
                break;
1✔
937
            }
938
        }
939

940
        $this->log->log([
1✔
941
            'type' => Log::TYPE_USER,
1✔
942
            'subtype' => Log::SUBTYPE_SUSPECT,
1✔
943
            'byuser' => $me ? $me->getId() : NULL,
1✔
944
            'user' => $userid,
1✔
945
            'text' => $text . ($heldby ? (", held $heldby") : '')
1✔
946
        ]);
1✔
947

948
        # Don't want to lose any existing reason, but update the user when removal is requested so that we
949
        # know who's asking.
950
        $spammers = $this->dbhr->preQuery("SELECT * FROM spam_users WHERE id = ?;", [ $id ]);
1✔
951
        foreach ($spammers as $spammer) {
1✔
952
            $sql = "UPDATE spam_users SET collection = ?, reason = ?, byuserid = ?, heldby = ?, heldat = CASE WHEN ? IS NOT NULL THEN NOW() ELSE NULL END WHERE id = ?;";
1✔
953
            $rc = $this->dbhm->preExec($sql, [
1✔
954
                $collection,
1✔
955
                $reason ? $reason : $spammer['reason'],
1✔
956
                $collection == Spam::TYPE_PENDING_REMOVE && $me ? $me->getId() : $spammer['byuserid'],
1✔
957
                $heldby,
1✔
958
                $heldby,
1✔
959
                $id
1✔
960
            ]);
1✔
961
        }
962

963
        $id = $rc ? $this->dbhm->lastInsertId() : NULL;
1✔
964

965
        return($id);
1✔
966
    }
967

968
    public function deleteSpammer($id, $reason) {
969
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
1✔
970
        $spammers = $this->dbhr->preQuery("SELECT * FROM spam_users WHERE id = ?;", [ $id ]);
1✔
971

972
        $rc = FALSE;
1✔
973

974
        foreach ($spammers as $spammer) {
1✔
975
            $rc = $this->dbhm->preExec("DELETE FROM spam_users WHERE id = ?;", [
1✔
976
                $id
1✔
977
            ]);
1✔
978

979
            $this->log->log([
1✔
980
                'type' => Log::TYPE_USER,
1✔
981
                'subtype' => Log::SUBTYPE_SUSPECT,
1✔
982
                'byuser' => $me ? $me->getId() : NULL,
1✔
983
                'user' => $spammer['userid'],
1✔
984
                'text' => "Removed: $reason"
1✔
985
            ]);
1✔
986
        }
987

988
        return($rc);
1✔
989
    }
990

991
    public function isSpammer($email) {
992
        $ret = FALSE;
186✔
993

994
        if ($email) {
186✔
995
            $u = new User($this->dbhr, $this->dbhm);
186✔
996
            $uid = $u->findByEmail($email);
186✔
997

998
            if ($uid) {
186✔
999
                $ret = $this->isSpammerUid($uid);
117✔
1000
            }
1001
        }
1002

1003
        return($ret);
186✔
1004
    }
1005

1006
    public function isSpammerUid($uid, $collection = Spam::TYPE_SPAMMER) {
1007
        $ret = FALSE;
414✔
1008

1009
        $spammers = $this->dbhr->preQuery("SELECT id FROM spam_users WHERE userid = ? AND collection = ?;", [
414✔
1010
            $uid,
414✔
1011
            $collection
414✔
1012
        ]);
414✔
1013

1014
        foreach ($spammers as $spammer) {
414✔
1015
            $ret = TRUE;
4✔
1016
        }
1017

1018
        return($ret);
414✔
1019
    }
1020

1021
    public function checkReplyDistance(
1022
        $userid,
1023
        $lat,
1024
        $lng,
1025
    ) {
1026
        # Check if they've replied to multiple posts across a wide area recently.  Ignore any messages outside
1027
        # a bounding box for the UK, because then it's those messages that are suspicious, and this member the
1028
        # poor sucker who they are trying to scam.
1029
        $suspect = FALSE;
26✔
1030
        $since = date('Y-m-d', strtotime("midnight 90 days ago"));
26✔
1031
        $dists = $this->dbhm->preQuery(
26✔
1032
            "SELECT DISTINCT MAX(messages.lat) AS maxlat, MIN(messages.lat) AS minlat, MAX(messages.lng) AS maxlng, MIN(messages.lng) AS minlng, groups.id AS groupid, groups.nameshort, groups.settings FROM chat_messages 
26✔
1033
    INNER JOIN messages ON messages.id = chat_messages.refmsgid 
1034
    INNER JOIN messages_groups ON messages_groups.msgid = messages.id
1035
    INNER JOIN `groups` ON groups.id = messages_groups.groupid
1036
    WHERE userid = ? AND chat_messages.date >= ? AND chat_messages.type = ? AND messages.lat IS NOT NULL AND messages.lng IS NOT NULL AND
1037
     messages.lng >= -7.57216793459 AND messages.lat >= 49.959999905 AND messages.lng <= 1.68153079591 AND messages.lat <= 58.6350001085;",
26✔
1038
            [
26✔
1039
                $userid,
26✔
1040
                $since,
26✔
1041
                ChatMessage::TYPE_INTERESTED
26✔
1042
            ]
26✔
1043
        );
26✔
1044

1045
        if (($dists[0]['maxlat'] || $dists[0]['minlat'] || $dists[0]['maxlng'] || $dists[0]['minlng']) && ($lat || $lng)) {
26✔
1046
            # Add the lat/lng we're interested in into the mix.
1047
            $maxlat = max($dists[0]['maxlat'], $lat);
1✔
1048
            $minlat = min($dists[0]['minlat'], $lat);
1✔
1049
            $maxlng = max($dists[0]['maxlng'], $lng);
1✔
1050
            $minlng = min($dists[0]['minlng'], $lng);
1✔
1051

1052
            $dist = \GreatCircle::getDistance($minlat, $minlng, $maxlat, $maxlng);
1✔
1053
            $dist = round($dist * 0.000621371192);
1✔
1054
            $settings = Utils::pres('settings', $dists[0]) ? json_decode($dists[0]['settings'], TRUE) : [
1✔
1055
                'spammers' => [
1✔
1056
                    'replydistance' => Spam::DISTANCE_THRESHOLD
1✔
1057
                ]
1✔
1058
            ];
1✔
1059

1060
            $replydist = array_key_exists('spammers', $settings) && array_key_exists(
1✔
1061
                'replydistance',
1✔
1062
                $settings['spammers']
1✔
1063
            ) ? $settings['spammers']['replydistance'] : Spam::DISTANCE_THRESHOLD;
1✔
1064

1065
            error_log("...compare $dist vs $replydist for group {$dists[0]['groupid']} settings " . json_encode($settings['spammers']));
1✔
1066

1067
            if ($replydist > 0 && $dist >= $replydist) {
1✔
1068
                # Check if it is greater than the current distance, so we don't keep asking for the same user
1069
                $rounded = round($dist / 5) * 5;
1✔
1070
                $existing = $this->dbhr->preQuery("SELECT replyambit FROM users WHERE id = ?;", [
1✔
1071
                    $userid
1✔
1072
                ]);
1✔
1073

1074
                if ($rounded > $existing[0]['replyambit']) {
1✔
1075
                    $this->dbhm->preExec("UPDATE users SET replyambit = ? WHERE id = ?;", [
1✔
1076
                        $rounded,
1✔
1077
                        $userid
1✔
1078
                    ]);
1✔
1079

1080
                    $suspect = TRUE;
1✔
1081
                    $reason = "Replied to posts $dist miles apart (threshold on {$dists[0]['nameshort']} $replydist)";
1✔
1082
                    $suspectgroups[] = $dists[0]['groupid'];
1✔
1083
                }
1084
            }
1085
        }
1086

1087
        return [ $suspect, $reason, $suspectgroups ];
26✔
1088
    }
1089

1090
    /**
1091
     * Find and flag users who share IPs with known spammers or muted users.
1092
     * This replaces the logic from spam_toddlers.php cron job.
1093
     *
1094
     * @param array $processedUsers Array of user IDs already processed (passed by reference, will be updated)
1095
     * @return array Results with counts and actions taken
1096
     */
1097
    public function findRelatedSpammers(&$processedUsers = [])
1098
    {
UNCOV
1099
        $results = [
×
UNCOV
1100
            'muted_users_checked' => 0,
×
UNCOV
1101
            'spammers_checked' => 0,
×
UNCOV
1102
            'users_flagged' => 0,
×
UNCOV
1103
            'users_muted' => 0,
×
UNCOV
1104
            'actions' => [],
×
UNCOV
1105
        ];
×
1106

1107
        $loki = Loki::getInstance();
×
1108

1109
        # Find active users who are muted on ChitChat.
1110
        $mutedUsers = $this->dbhr->preQuery(
×
1111
            "SELECT DISTINCT id AS userid FROM users WHERE newsfeedmodstatus = ?",
×
1112
            [User::NEWSFEED_SUPPRESSED]
×
UNCOV
1113
        );
×
1114

UNCOV
1115
        foreach ($mutedUsers as &$user) {
×
UNCOV
1116
            $user['newsfeed'] = TRUE;
×
1117
        }
1118
        $results['muted_users_checked'] = count($mutedUsers);
×
1119

1120
        # Find active users who are on the spammer list.
UNCOV
1121
        $spammers = $this->dbhr->preQuery(
×
1122
            "SELECT DISTINCT userid FROM spam_users WHERE collection = ?",
×
1123
            [Spam::TYPE_SPAMMER]
×
UNCOV
1124
        );
×
1125

UNCOV
1126
        foreach ($spammers as &$spammer) {
×
UNCOV
1127
            $spammer['spam'] = TRUE;
×
1128
        }
1129
        $results['spammers_checked'] = count($spammers);
×
1130

1131
        foreach (array_merge($spammers, $mutedUsers) as $user) {
×
1132
            # Find users who shared IPs with this user within a 5 minute window.
1133
            $sharedUsers = $loki->findUsersWithSharedIPActivity($user['userid'], 5, '7d');
×
1134

UNCOV
1135
            foreach ($sharedUsers as $otherUserId) {
×
1136
                if (!array_key_exists($otherUserId, $processedUsers)) {
×
UNCOV
1137
                    $processedUsers[$otherUserId] = 1;
×
1138
                    $u = User::get($this->dbhr, $this->dbhm, $otherUserId);
×
1139

1140
                    if ($u->getPrivate('systemrole') === User::SYSTEMROLE_USER) {
×
1141
                        # Check that the shared IPs haven't been used by a mod.
1142
                        $ips = $loki->findUserIPs($otherUserId, '7d');
×
1143
                        $modUsedIP = FALSE;
×
1144

1145
                        foreach ($ips as $ip) {
×
UNCOV
1146
                            if ($loki->hasModUsedIP($ip, '30d')) {
×
1147
                                $modUsedIP = TRUE;
×
UNCOV
1148
                                break;
×
1149
                            }
1150
                        }
1151

1152
                        if (!$modUsedIP) {
×
1153
                            $reason = "User $otherUserId used same IP within 5 minutes of {$user['userid']}";
×
1154

1155
                            if (Utils::pres('spam', $user)) {
×
UNCOV
1156
                                $reason .= " who is on the spammer list.";
×
UNCOV
1157
                                $this->addSpammer($otherUserId, Spam::TYPE_PENDING_ADD, $reason);
×
UNCOV
1158
                                $results['users_flagged']++;
×
1159
                                $results['actions'][] = [
×
1160
                                    'type' => 'flagged',
×
UNCOV
1161
                                    'userid' => $otherUserId,
×
1162
                                    'reason' => $reason,
×
1163
                                ];
×
1164
                            } else {
1165
                                $reason .= " who is muted on ChitChat.";
×
1166

1167
                                if ($u->getPrivate('newsfeedmodstatus') != User::NEWSFEED_SUPPRESSED) {
×
1168
                                    $u->setPrivate('newsfeedmodstatus', User::NEWSFEED_SUPPRESSED);
×
1169
                                    $results['users_muted']++;
×
1170
                                    $results['actions'][] = [
×
UNCOV
1171
                                        'type' => 'muted',
×
1172
                                        'userid' => $otherUserId,
×
UNCOV
1173
                                        'reason' => $reason,
×
1174
                                    ];
×
1175
                                }
1176
                            }
1177
                        }
1178
                    }
1179
                }
1180
            }
1181
        }
1182

UNCOV
1183
        return $results;
×
1184
    }
1185
}
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