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

Freegle / iznik-server / af568832-076b-46b2-86a8-a1b35eacbc21

pending completion
af568832-076b-46b2-86a8-a1b35eacbc21

push

circleci

edwh
Test fixes.

19710 of 20786 relevant lines covered (94.82%)

32.43 hits per line

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

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

4

5
require_once(IZNIK_BASE . '/lib/GreatCircle.php');
1✔
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

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

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

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

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

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

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

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

64
    private $spamwords = NULL;
65

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

194
        # Now check if this sender has mailed a lot of owners recently.
195
        $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")) . "'";
183✔
196
        $counts = $this->dbhr->preQuery($sql, [
183✔
197
            $msg->getEnvelopefrom()
183✔
198
        ]);
199

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

206
        # Now check if this subject line has been used in mails to lots of owners recently.
207
        $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")) . "'";
183✔
208
        $counts = $this->dbhr->preQuery($sql, [
183✔
209
            $msg->getSubject()
183✔
210
        ]);
211

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

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

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

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

231
            $line1greeting = FALSE;
6✔
232
            $line3greeting = FALSE;
6✔
233
            $subjgreeting = FALSE;
6✔
234

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

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

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

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

254
        $spammail = $this->checkReferToSpammer($text);
182✔
255

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

260
        # For messages we want to spot any dubious items.
261
        $r = $this->checkSpam($text, [ Spam::ACTION_REVIEW, Spam::ACTION_SPAM ]);
181✔
262
        if ($r) {
181✔
263
            return ($r);
3✔
264
        }
265

266
        # It's fine.  So far as we know.
267
        return(NULL);
180✔
268
    }
269

270
    private function getSpamWords() {
271
        if (!$this->spamwords) {
201✔
272
            $this->spamwords = $this->dbhr->preQuery("SELECT * FROM spam_keywords;");
201✔
273
        }
274
    }
275

276
    public function checkReview($message, $language) {
277
        # Spammer trick is to encode the dot in URLs.
278
        $message = str_replace('&#12290;', '.', $message);
52✔
279

280
        #error_log("Check review $message len " . strlen($message));
281
        if (strlen($message) == 0) {
52✔
282
            # Blank is odd, but not spam.
283
            return NULL;
×
284
        }
285

286
        $check = NULL;
52✔
287

288
        if (!$check && stripos($message, '<script') !== FALSE) {
52✔
289
            # Looks dodgy.
290
            $check = self::REASON_SCRIPT;
1✔
291
        }
292

293
        if (!$check) {
52✔
294
            # Check for URLs.
295
            if (preg_match_all(Utils::URL_PATTERN, $message, $matches)) {
52✔
296
                # A link.  Some domains are ok - where they have been whitelisted several times (to reduce bad whitelists).
297
                $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%';");
7✔
298

299
                $valid = 0;
7✔
300
                $count = 0;
7✔
301
                $badurl = NULL;
7✔
302

303
                foreach ($matches as $val) {
7✔
304
                    foreach ($val as $url) {
7✔
305
                        $bad = FALSE;
7✔
306
                        $url2 = str_replace('http:', '', $url);
7✔
307
                        $url2 = str_replace('https:', '', $url2);
7✔
308
                        foreach (Utils::URL_BAD as $badone) {
7✔
309
                            if (strpos($url2, $badone) !== FALSE) {
7✔
310
                                $bad = TRUE;
×
311
                            }
312
                        }
313

314
                        if (!$bad && strlen($url) > 0) {
7✔
315
                            $url = substr($url, strpos($url, '://') + 3);
7✔
316
                            $count++;
7✔
317
                            $trusted = FALSE;
7✔
318

319
                            foreach ($ourdomains as $domain) {
7✔
320
                                if (stripos($url, $domain['domain']) === 0) {
7✔
321
                                    # One of our domains.
322
                                    $valid++;
1✔
323
                                    $trusted = TRUE;
1✔
324
                                }
325
                            }
326

327
                            $badurl = $trusted ? $badurl : $url;
7✔
328
                        }
329
                    }
330
                }
331

332
                if ($valid < $count) {
7✔
333
                    # At least one URL which we don't trust.
334
                    $check = self::REASON_LINK;
7✔
335
                }
336
            }
337
        }
338

339
        if (!$check) {
52✔
340
            # Check keywords
341
            $this->getSpamWords();
46✔
342
            foreach ($this->spamwords as $word) {
46✔
343
                $w = $word['type'] == 'Literal' ? preg_quote($word['word']) : $word['word'];
46✔
344

345
                if ($word['action'] == 'Review' &&
46✔
346
                    preg_match('/\b' . $w . '\b/i', $message) &&
46✔
347
                    (!$word['exclude'] || !preg_match('/' . $word['exclude'] . '/i', $message))) {
46✔
348
                    #error_log("Spam keyword {$word['word']}");
349
                    $check = self::REASON_KNOWN_KEYWORD;
1✔
350
                }
351
            }
352
        }
353

354
        if (!$check && (strpos($message, '$') !== FALSE || strpos($message, '£') !== FALSE || strpos($message, '(a)') !== FALSE)) {
52✔
355
            $check = self::REASON_MONEY;
1✔
356
        }
357

358
        # Email addresses are suspect too; a scammer technique is to take the conversation offlist.
359
        if (!$check && preg_match_all(Message::EMAIL_REGEXP, $message, $matches)) {
52✔
360
            foreach ($matches as $val) {
3✔
361
                foreach ($val as $email) {
3✔
362
                    if (!Mail::ourDomain($email) && strpos($email, 'trashnothing') === FALSE && strpos($email, 'yahoogroups') === FALSE) {
3✔
363
                        $check = self::REASON_EMAIL;
2✔
364
                    }
365
                }
366
            }
367
        }
368

369
        if (!$check && $this->checkReferToSpammer($message)) {
52✔
370
            $check = self::REASON_REFERRED_TO_SPAMMER;
1✔
371
        }
372

373
        if (!$check && $language) {
52✔
374
            # Check language is English.  This isn't out of some kind of misplaced nationalistic fervour, but just
375
            # because our spam filters work less well on e.g. French.
376
            #
377
            # Short strings like 'test' or 'ok thanks' or 'Eileen', don't always come out as English, so only check
378
            # slightly longer messages where the identification is more likely to work.
379
            #
380
            # We check that English is the most likely, or fairly likely compared to the one chosen.
381
            #
382
            # This is a fairly lax test but spots text which is very probably in another language.
383
            $message = strtolower(trim($message));
44✔
384

385
            if (strlen($message) > 50) {
44✔
386
                $ld = new Language;
12✔
387
                $lang = $ld->detect($message)->close();
12✔
388
                reset($lang);
12✔
389
                $firstlang = key($lang);
12✔
390
                $firstprob = Utils::presdef($firstlang, $lang, 0);
12✔
391
                $enprob = Utils::presdef('en', $lang, 0);
12✔
392
                $cyprob = Utils::presdef('cy', $lang, 0);
12✔
393
                $ourprob = max($enprob, $cyprob);
12✔
394

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

397
                if ($check) {
12✔
398
                    $check = self::REASON_LANGUAGE;
1✔
399
                }
400
            }
401
        }
402

403
        return($check);
52✔
404
    }
405

406
    public function checkSpam($message, $actions) {
407
        $ret = NULL;
199✔
408

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

412
        # Some spammers use HTML entities in text bodyparts to disguise words.
413
        $message = str_replace('&#616;', 'i', $message);
199✔
414
        $message = str_replace('&#537;', 's', $message);
199✔
415
        $message = str_replace('&#206;', 'I', $message);
199✔
416

417
        # Check keywords which are known as spam.
418
        $this->getSpamWords();
199✔
419
        foreach ($this->spamwords as $word) {
199✔
420
            if (strlen(trim($word['word'])) > 0) {
199✔
421
                $exp = '/\b' . preg_quote($word['word']) . '\b/i';
199✔
422
                if (in_array($word['action'], $actions) &&
199✔
423
                    preg_match($exp, $message) &&
199✔
424
                    (!$word['exclude'] || !preg_match('/' . $word['exclude'] . '/i', $message))) {
199✔
425
                    $ret = array(true, Spam::REASON_KNOWN_KEYWORD, "Refers to keyword '{$word['word']}'");
4✔
426
                }
427
            }
428
        }
429

430
        # Check whether any URLs are in Spamhaus DBL black list.
431
        if (preg_match_all(Utils::URL_PATTERN, $message, $matches)) {
199✔
432
            $checked = [];
7✔
433

434
            foreach ($matches as $val) {
7✔
435
                foreach ($val as $url) {
7✔
436
                    $bad = FALSE;
7✔
437
                    $url2 = str_replace('http:', '', $url);
7✔
438
                    $url2 = str_replace('https:', '', $url2);
7✔
439
                    foreach (Utils::URL_BAD as $badone) {
7✔
440
                        if (strpos($url2, $badone) !== FALSE) {
7✔
441
                            $bad = TRUE;
×
442
                        }
443
                    }
444

445
                    if (!$bad && strlen($url) > 0) {
7✔
446
                        $url = substr($url, strpos($url, '://') + 3);
7✔
447

448
                        if (array_key_exists($url, $checked)) {
7✔
449
                            # We do this part for performance and part because we've seen hangs in dns_get_record
450
                            # when checking Spamhaus repeatedly in UT.
451
                            $ret = $checked[$url];
×
452
                        }
453

454
                        if (Mail::checkSpamhaus("http://$url")) {
7✔
455
                            $ret = [ TRUE, Spam::REASON_DBL, "Blacklisted url $url" ];
×
456
                            $checked[$url] = $ret;
×
457
                        }
458

459
                        if (preg_match('/.+' . GROUP_DOMAIN . '/', $url) || preg_match('/.+' . USER_DOMAIN . '/', $url)) {
7✔
460
                            # A domain which embeds one of ours in an attempt to fool us into thinking it is legit.
461
                            $ret = [ TRUE, Spam::REASON_USED_OUR_DOMAIN, "Used our domain inside $url" ] ;
×
462
                            $checked[$url] = $ret;
×
463
                        }
464
                    }
465
                }
466
            }
467
        }
468

469
        return($ret);
199✔
470
    }
471

472
    public function checkReferToSpammer($text) {
473
        $ret = NULL;
235✔
474

475
        if (strpos($text, '@') !== FALSE) {
235✔
476
            # Check if it contains a reference to a known spammer.
477
            if (preg_match_all(Message::EMAIL_REGEXP, $text, $matches)) {
4✔
478
                foreach ($matches as $val) {
4✔
479
                    foreach ($val as $email) {
4✔
480
                        $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 ?;", [
4✔
481
                            Spam::TYPE_SPAMMER,
482
                            $email
483
                        ]);
484

485
                        $ret = count($spammers) > 0 ? $spammers[0]['email'] : NULL;
4✔
486

487
                        if ($ret) {
4✔
488
                            break;
2✔
489
                        }
490
                    }
491
                }
492
            }
493
        }
494

495
        return($ret);
235✔
496
    }
497

498
    public function notSpamSubject($subj) {
499
        $sql = "INSERT IGNORE INTO spam_whitelist_subjects (subject, comment) VALUES (?, 'Marked as not spam');";
1✔
500
        $this->dbhm->preExec($sql, [ $subj ]);
1✔
501
    }
502

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

508
        $u = User::get($this->dbhr, $this->dbhm, $userid);
422✔
509

510
        if ($u->getId() == $userid && $u->isModerator()) {
422✔
511
            # We whitelist all mods.
512
            return FALSE;
119✔
513
        }
514

515
        $suspect = FALSE;
399✔
516
        $reason = NULL;
399✔
517
        $suspectgroups = [];
399✔
518

519
        if ($checkmemberships) {
399✔
520
            # Check whether they have applied to a suspicious number of groups, but exclude whitelisted members.
521
            #
522
            # If we have just added a membership then it may not have been logged yet, so we might fail to count
523
            # it.  This happens in UT.
524
            $groupq = $groupJustAdded ? " AND logs.groupid != $groupJustAdded " : '';
398✔
525
            $start = date('Y-m-d', strtotime("365 days ago"));
398✔
526

527
            $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 >= ?;";
398✔
528
            $counts = $this->dbhr->preQuery($sql, [
398✔
529
                Spam::TYPE_WHITELIST,
530
                $userid,
531
                Log::TYPE_GROUP,
532
                Log::SUBTYPE_JOINED,
533
                $start
534
            ]);
535

536
            $count = $counts[0]['count'];
398✔
537

538
            if ($groupJustAdded) {
398✔
539
                $count++;
398✔
540
            }
541

542
            if ($count > Spam::SEEN_THRESHOLD) {
398✔
543
                $suspect = true;
4✔
544
                $reason = "Seen on many groups";
4✔
545
            }
546
        }
547

548
        if (!$suspect) {
399✔
549
            # Check if they've replied to multiple posts across a wide area recently.  Ignore any messages outside
550
            # a bounding box for the UK, because then it's those messages that are suspicious, and this member the
551
            # poor sucker who they are trying to scam.
552
            $since = date('Y-m-d', strtotime("midnight 90 days ago"));
399✔
553
            $dists = $this->dbhm->preQuery("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 
399✔
554
    INNER JOIN messages ON messages.id = chat_messages.refmsgid 
555
    INNER JOIN messages_groups ON messages_groups.msgid = messages.id
556
    INNER JOIN `groups` ON groups.id = messages_groups.groupid
557
    WHERE userid = ? AND chat_messages.date >= ? AND chat_messages.type = ? AND messages.lat IS NOT NULL AND messages.lng IS NOT NULL AND
558
     messages.lng >= -7.57216793459 AND messages.lat >= 49.959999905 AND messages.lng <= 1.68153079591 AND messages.lat <= 58.6350001085;", [
559
                $userid,
560
                $since,
561
                ChatMessage::TYPE_INTERESTED
562
            ]);
563

564
            if (($dists[0]['maxlat'] || $dists[0]['minlat'] || $dists[0]['maxlng'] || $dists[0]['minlng']) && ($lat || $lng)) {
399✔
565
                # Add the lat/lng we're interested in into the mix.
566
                $maxlat = max($dists[0]['maxlat'], $lat);
1✔
567
                $minlat = min($dists[0]['minlat'], $lat);
1✔
568
                $maxlng = max($dists[0]['maxlng'], $lng);
1✔
569
                $minlng = min($dists[0]['minlng'], $lng);
1✔
570

571
                $dist = \GreatCircle::getDistance($minlat, $minlng, $maxlat, $maxlng);
1✔
572
                $dist = round($dist * 0.000621371192);
1✔
573
                $settings = Utils::pres('settings', $dists[0]) ? json_decode($dists[0]['settings'], TRUE) : [
1✔
574
                    'spammers' => [
575
                        'replydistance' => Spam::DISTANCE_THRESHOLD
1✔
576
                    ]
577
                ];
578

579
                $replydist = array_key_exists('spammers', $settings) && array_key_exists('replydistance', $settings['spammers']) ? $settings['spammers']['replydistance'] : Spam::DISTANCE_THRESHOLD;
1✔
580

581
                if ($replydist > 0 && $dist >= $replydist) {
1✔
582
                    # Check if it is greater than the current distance, so we don't keep asking for the same user
583
                    $rounded = round($dist / 5) * 5;
1✔
584
                    $existing = $this->dbhr->preQuery("SELECT replyambit FROM users WHERE id = ?;", [
1✔
585
                        $userid
586
                    ]);
587

588
                    if ($rounded > $existing[0]['replyambit']) {
1✔
589
                        $this->dbhm->preExec("UPDATE users SET replyambit = ? WHERE id = ?;", [
1✔
590
                            $rounded,
591
                            $userid
592
                        ]);
593

594
                        $suspect = TRUE;
1✔
595
                        $reason = "Replied to posts $dist miles apart (threshold on {$dists[0]['nameshort']} $replydist)";
1✔
596
                        $suspectgroups[] = $dists[0]['groupid'];
1✔
597
                    }
598
                }
599
            }
600
        }
601

602
        if ($suspect) {
399✔
603
            # This user is suspect.  We will mark it as so, which means that it'll show up to mods on relevant groups,
604
            # and they will review it.
605
            $this->log->log([
5✔
606
                'type' => Log::TYPE_USER,
607
                'subtype' => Log::SUBTYPE_SUSPECT,
608
                'byuser' => $me ? $me->getId() : NULL,
5✔
609
                'user' => $userid,
610
                'text' => $reason
611
            ]);
612

613
            $memberships = $u->getMemberships();
5✔
614

615
            foreach ($memberships as $membership) {
5✔
616
                if (!count($suspectgroups) || in_array($membership['id'], $suspectgroups)) {
4✔
617
                    $u->memberReview($membership['id'], TRUE, $reason);
4✔
618
                }
619
            }
620

621
            User::clearCache($userid);
5✔
622
        }
623

624
        return $suspect;
399✔
625
    }
626

627
    public function collectionCounts() {
628
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
26✔
629

630
        $ret = [
26✔
631
            Spam::TYPE_PENDING_ADD => 0,
632
            Spam::TYPE_PENDING_REMOVE => 0
633
        ];
634

635
        if ($me && $me->hasPermission(User::PERM_SPAM_ADMIN)) {
26✔
636
            $sql = "SELECT COUNT(*) AS count, collection FROM spam_users WHERE collection IN (?, ?) GROUP BY collection;";
1✔
637
            $counts = $this->dbhr->preQuery(
1✔
638
                $sql,
639
                [
640
                    Spam::TYPE_PENDING_ADD,
641
                    Spam::TYPE_PENDING_REMOVE
642
                ]
643
            );
644

645
            foreach ($counts as $count) {
1✔
646
                $ret[$count['collection']] = $count['count'];
1✔
647
            }
648
        }
649

650
        return($ret);
26✔
651
    }
652

653
    public function exportSpammers() {
654
        $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✔
655
        $spammers = $this->dbhr->preQuery($sql, [ Spam::TYPE_SPAMMER ]);
1✔
656
        return($spammers);
1✔
657
    }
658

659
    public function listSpammers($collection, $search, &$context) {
660
        # We exclude anyone who isn't a User (e.g. mods, support, admin) so that they don't appear on the list and
661
        # get banned.
662
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
2✔
663
        $seeall = $me && $me->isAdminOrSupport();
2✔
664
        $collectionq = ($collection ? " AND collection = '$collection'" : '');
2✔
665
        $startq = $context ? (" AND spam_users.id <  " . intval($context['id']) . " ") : '';
2✔
666
        $searchq = is_null($search) ? '' : (" AND (users_emails.email LIKE " . $this->dbhr->quote("%$search%") . " OR users.fullname LIKE " . $this->dbhr->quote("%$search%") . ") ");
2✔
667
        $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✔
668
        $context = [];
2✔
669

670
        $spammers = $this->dbhr->preQuery($sql);
2✔
671
        $u = new User($this->dbhr, $this->dbhm);
2✔
672
        $spammeruids = array_filter(array_unique(array_column($spammers, 'userid')));
2✔
673
        $users = $u->getPublicsById($spammeruids, NULL, TRUE, $seeall);
2✔
674
        $ctx = NULL;
2✔
675
        $moduids = array_filter(array_unique(array_merge(array_column($spammers, 'byuserid'), array_column($spammers, 'heldby'))));
2✔
676
        $users2 = $u->getPublicsById($moduids, NULL, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE);
2✔
677
        $users = array_replace($users, $users2);
2✔
678
        $emails = $u->getEmailsById(array_merge($spammeruids, $moduids));
2✔
679

680
        foreach ($spammers as &$spammer) {
2✔
681
            $spammer['user'] = $users[$spammer['userid']];
1✔
682

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

685
            foreach ($es as $anemail) {
1✔
686
                if (!Utils::pres('email', $spammer['user']) && !Mail::ourDomain($anemail['email']) && strpos($anemail['email'], '@yahoogroups.') === FALSE) {
1✔
687
                    $spammer['user']['email'] = $anemail['email'];
1✔
688
                }
689
            }
690

691
            $others = [];
1✔
692

693
            foreach ($es as $anemail) {
1✔
694
                if ($anemail['email'] != $spammer['user']['email']) {
1✔
695
                    $others[] = $anemail;
1✔
696
                }
697
            }
698

699
            uasort($others, function($a, $b) {
1✔
700
                return(strcmp($a['email'], $b['email']));
1✔
701
            });
702

703
            $spammer['user']['otheremails'] = $others;
1✔
704

705
            if ($spammer['byuserid']) {
1✔
706
                $spammer['byuser'] = $users[$spammer['byuserid']];
1✔
707

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

711
                    foreach ($es as $anemail) {
1✔
712
                        if ($anemail['email'] != $spammer['user']['email']) {
1✔
713
                            $others[] = $anemail;
1✔
714
                        }
715

716
                        if (!Utils::pres('email', $spammer['byuser']) && !Mail::ourDomain($anemail['email']) && strpos($anemail['email'], '@yahoogroups.') === FALSE) {
1✔
717
                            $spammer['byuser']['email'] = $anemail['email'];
×
718
                        }
719
                    }
720
                }
721
            }
722

723
            if ($collection ==  Spam::TYPE_PENDING_ADD) {
1✔
724
                if (Utils::pres('heldby', $spammer)) {
1✔
725
                    $spammer['user']['heldby'] = $users[$spammer['heldby']];
1✔
726
                    $spammer['user']['heldat'] = Utils::ISODate($spammer['heldat']);
1✔
727
                    unset($spammer['heldby']);
1✔
728
                    unset($spammer['heldat']);
1✔
729
                }
730
            }
731

732
            $spammer['added'] = Utils::ISODate($spammer['added']);
1✔
733

734
            # Add in any other users who have recently used the same IP.
735
            $spammer['sameip'] = [];
1✔
736

737
            $ips = $this->dbhr->preQuery("SELECT DISTINCT(ip) FROM logs_api WHERE userid = ?;", [
1✔
738
                $spammer['userid']
1✔
739
            ]);
740

741
            foreach ($ips as $ip) {
1✔
742
                $otherusers = $this->dbhr->preQuery("SELECT DISTINCT userid FROM logs_api WHERE ip = ? AND userid != ?;", [
×
743
                    $ip['ip'],
×
744
                    $spammer['userid']
×
745
                ]);
746

747
                foreach ($otherusers as $otheruser) {
×
748
                    $spammer['sameip'][] = $otheruser['userid'];
×
749
                }
750
            }
751

752
            $spammer['sameip'] = array_unique($spammer['sameip']);
1✔
753

754
            $context['id'] = $spammer['id'];
1✔
755
        }
756

757
        return($spammers);
2✔
758
    }
759

760
    public function getSpammer($id) {
761
        $sql = "SELECT * FROM spam_users WHERE id = ?;";
1✔
762
        $ret = NULL;
1✔
763

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

766
        foreach ($spams as $spam) {
1✔
767
            $ret = $spam;
1✔
768
        }
769

770
        return($ret);
1✔
771
    }
772

773
    public function getSpammerByUserid($userid, $collection = Spam::TYPE_SPAMMER) {
774
        $sql = "SELECT * FROM spam_users WHERE userid = ? AND collection = ?;";
51✔
775
        $ret = NULL;
51✔
776

777
        $spams = $this->dbhr->preQuery($sql, [ $userid, $collection ]);
51✔
778

779
        foreach ($spams as $spam) {
51✔
780
            $ret = $spam;
1✔
781
        }
782

783
        return($ret);
51✔
784
    }
785

786
    public function removeSpamMembers($groupid = NULL) {
787
        $count = 0;
1✔
788
        $groupq = $groupid ? " AND groupid = $groupid " : "";
1✔
789

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

795
        foreach ($spammers as $spammer) {
1✔
796
            error_log("Found spammer {$spammer['userid']}");
1✔
797
            $u = User::get($this->dbhr, $this->dbhm, $spammer['userid']);
1✔
798
            error_log("Remove spammer {$spammer['userid']}");
1✔
799
            $u->removeMembership($spammer['groupid'], TRUE, TRUE);
1✔
800
            $count++;
1✔
801
        }
802

803
        # Find any messages from spammers which are on groups.
804
        $groupq = $groupid ? " AND messages_groups.groupid = $groupid " : "";
1✔
805
        $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✔
806
        $spammsgs = $this->dbhr->preQuery($sql, [
1✔
807
            Spam::TYPE_SPAMMER
808
        ]);
809

810
        foreach ($spammsgs as $spammsg) {
1✔
811
            error_log("Found spam message {$spammsg['id']}");
1✔
812
            $m = new Message($this->dbhr, $this->dbhm, $spammsg['id']);
1✔
813
            $m->delete("From known spammer {$spammsg['reason']}");
1✔
814
            $count++;
1✔
815
        }
816

817
        # Find any chat messages from spammers.
818
        $chats = $this->dbhr->preQuery("SELECT id, chatid FROM chat_messages WHERE userid IN (SELECT userid FROM spam_users WHERE collection = 'Spammer');");
1✔
819
        foreach ($chats as $chat) {
1✔
820
            error_log("Found spam chat message {$chat['id']}");
×
821
            $sql = "UPDATE chat_messages SET reviewrejected = 1, reviewrequired = 0 WHERE id = ?";
×
822
            $this->dbhm->preExec($sql, [ $chat['id'] ]);
×
823
        }
824

825
        # Delete any newsfeed items from spammers.
826
        $newsfeeds = $this->dbhr->preQuery("SELECT id FROM newsfeed WHERE userid IN (SELECT userid FROM spam_users WHERE collection = 'Spammer');");
1✔
827
        foreach ($newsfeeds as $newsfeed) {
1✔
828
            error_log("Delete newsfeed item {$newsfeed['id']}");
×
829
            $sql = "DELETE FROM newsfeed WHERE id = ?;";
×
830
            $this->dbhm->preExec($sql, [ $newsfeed['id'] ]);
×
831
        }
832

833
        # Delete any notifications from spammers
834
        $notifs = $this->dbhr->preQuery("SELECT id FROM users_notifications WHERE fromuser IN (SELECT userid FROM spam_users WHERE collection = 'Spammer');");
1✔
835
        foreach ($notifs as $notif) {
1✔
836
            error_log("Delete notification {$notif['id']}");
×
837
            $sql = "DELETE FROM users_notifications WHERE id = ?;";
×
838
            $this->dbhm->preExec($sql, [ $notif['id'] ]);
×
839
        }
840

841
        # Remove any cases where the spammer has said they're waiting for a reply, which makes the spammee look
842
        # bad.
843
        $expecteds = $this->dbhr->preQuery("SELECT users_expected.id FROM `users_expected` INNER JOIN spam_users ON expecter = spam_users.userid AND collection = 'Spammer';");
1✔
844
        foreach ($expecteds as $expected) {
1✔
845
            error_log("Delete expected {$expected['id']}");
×
846
            $this->dbhm->preExec("DELETE FROM users_expected WHERE id = ?;", [
×
847
                $expected['id']
×
848
            ]);
849
        }
850

851
        return($count);
1✔
852
    }
853

854
    public function addSpammer($userid, $collection, $reason) {
855
        $me = Session::whoAmI($this->dbhr, $this->dbhm);
6✔
856
        $text = NULL;
6✔
857
        $id = NULL;
6✔
858

859
        switch ($collection) {
860
            case Spam::TYPE_WHITELIST: {
861
                $text = "Whitelisted: $reason";
1✔
862

863
                # Ensure nobody who is whitelisted is banned.
864
                $this->dbhm->preExec("DELETE FROM users_banned WHERE userid IN (SELECT userid FROM spam_users WHERE collection = ?);", [
1✔
865
                    Spam::TYPE_WHITELIST
866
                ]);
867
                break;
1✔
868
            }
869
            case Spam::TYPE_PENDING_ADD: {
870
                $text = "Reported: $reason";
2✔
871
                break;
2✔
872
            }
873
        }
874

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

883
        $proceed = TRUE;
6✔
884

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

890
            $ourDomain = FALSE;
2✔
891

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

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

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

916
            $id = $rc ? $this->dbhm->lastInsertId() : NULL;
5✔
917
        }
918

919
        return($id);
6✔
920
    }
921

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

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

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

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

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

969
        return($id);
1✔
970
    }
971

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

976
        $rc = FALSE;
1✔
977

978
        foreach ($spammers as $spammer) {
1✔
979
            $rc = $this->dbhm->preExec("DELETE FROM spam_users WHERE id = ?;", [
1✔
980
                $id
981
            ]);
982

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

992
        return($rc);
1✔
993
    }
994

995
    public function isSpammer($email) {
996
        $ret = FALSE;
176✔
997

998
        if ($email) {
176✔
999
            $u = new User($this->dbhr, $this->dbhm);
176✔
1000
            $uid = $u->findByEmail($email);
176✔
1001

1002
            if ($uid) {
176✔
1003
                $ret = $this->isSpammerUid($uid);
70✔
1004
            }
1005
        }
1006

1007
        return($ret);
176✔
1008
    }
1009

1010
    public function isSpammerUid($uid) {
1011
        $ret = FALSE;
154✔
1012

1013
        $spammers = $this->dbhr->preQuery("SELECT id FROM spam_users WHERE userid = ? AND collection = ?;", [
154✔
1014
            $uid,
1015
            Spam::TYPE_SPAMMER
1016
        ]);
1017

1018
        foreach ($spammers as $spammer) {
154✔
1019
            $ret = TRUE;
3✔
1020
        }
1021

1022
        return($ret);
154✔
1023
    }
1024
}
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