Чистка и модерирование и борьба со спамом на phpBB

Все ваши предложения по улучшению работы сервиса
Ответить
Аватара пользователя

Автор темы
foxss
Сообщения: 91
Зарегистрирован: 22 окт 2016, 00:17
Благодарил (а): 3 раза
Поблагодарили: 16 раз
Пол:

Чистка и модерирование и борьба со спамом на phpBB

Сообщение foxss » 14 май 2021, 23:27

Столкнулся с задаче почистить форум от спама, который добавлялся практически год. В общей сложности накопилось более 100.000 постов.

Разумным решением было бы переставить форум с нуля, но заказчик настоял именно на чистке. Ну что ж, Ваши деньги, любой каприз.


Решил определиться что попадает 100% под спам:
- Посты в которых 0 сообщений
- Посты не от админа
- Посты не содержащие ключевых слов

Все эти варианты можно смело сразу удалять, те которые не попадают под определенный нами фильтр, уже будут фильтроваться вручную:
- Просмотр заголовка
- Просмотр первого поста
- Просмотр последних постов

Окей, нужно приступать к модерации, но выводятся только по 25 сообщений... Значит надо увеличить этот параметр, делается это в админке в управлении форумами:
Админка > Forums > Edit (зеленая шестеренка) > Topics per page:

Максимально тут можно поставить 127. Если Вам надо больше (позволяет оперативка выделенная для скрипта, и его время выполнения), можно сделать хак, модифицировав запись непосредственно в таблице phpbb_forums, столбец: forum_topics_per_page, не забудьте так же ему поменять тип, на побольше.. Замечу, что это необходимо сделать для каждого из форумов.

Т.к. меня устраивало именно 127 на страницу, то я поставил именно это значение. Началась чистка.. Дабы ускорить процесс, я решил предварительно кильнуть пользователей спаммеров, у которых в несколько раз больше сообщений чем у самого общительного пользователя на форуме, отследить это можно по кол-ву сообщений пользователей в таблице Members list, которая находится по примерно такому пути www.forum.com/memberlist.php

Теперь непосредственно про само удаление.. это скучная и не благодарная работа 🙂 вскоре я понял, что мне для визуализации нужных постов, надо раскрасить те, которые удалять, возможно, не нужно. Можно это делать разными способами, я пошел самым простым и написал javascript плагин для GreaseMonkey. Собственно этот пост, именно для того и нужен чтобы этот скриптик не писать вновь, вот он:

Код: Выделить всё

    // ==UserScript==  
    // @name           IB  
    // @namespace      IB  
    // @include        http://site.com/forum/  
    // ==/UserScript==  

    var aPosts = document.getElementsByClassName('posts');  
    for (var i=0; i<aPosts.length; i++) {  
        var ch = aPosts[i].innerHTML.substring(0, 1);  
        if (ch!='0' && ch!='R') {  
            aPosts[i].parentNode.style.backgroundColor = '#FF0000';  
        }  

        var stopWords = Array('impotence', 'levitra', 'cialis', 'viagra', 'nude', 'adult', 'pharma', 'bdsm', 'apotheke');  
        for (var j=0; j<stopWords.length; j++) {  
            var expr = new RegExp(stopWords[j], 'gim');      
            if (expr.exec(aPosts[i].parentNode.innerHTML)) {  
                aPosts[i].parentNode.style.backgroundColor = 'transparent';      
                break;  
            }  
        }  

        var stopWords = Array('admin', 'iHit', 'shooting', 'support', 'telepoints');  
        for (var j=0; j<stopWords.length; j++) {  
            var expr = new RegExp(stopWords[j], 'gim');      
            if (expr.exec(aPosts[i].parentNode.innerHTML)) {  
                aPosts[i].parentNode.style.backgroundColor = '#00FF00';      
                break;  
            }  
        }  

    }
он ничего не фильтрует, просто помогает выделить из кучи постов, те на которые надо обратить внимание, а именно посты с сообщениями внутри красятся в красный цвет, посты с ключевыми словами красятся в салатовый, это очень ускорило мой процесс:
Изображение
Ну, а дальше Mark all > Delete

Хочу заметить, что т.к. я раньше с такой работой не сталкивался, то я довольно сильно продешевил, так что советую тем у кого есть подобное предложение, изначально попробовать сделать 15 минутный забег и засечь время которое Вам понадобиться на эту работу 🙂

Не забывайте делать бэкапы! Удачи!

Отправлено спустя 4 минуты 50 секунд:
После чистки, я писал для него защиту, которая вполне себе работает до сих пор, без использования дополнительных каптч и т.д.
При регистрации:
1) поле email делаем скрытым с помощью стилей
2) генерируем уникальное название, например хэш от ip + соль.
3) создаем поле с таким именем и располагаем его вместо скрытой формы email-а
4) при отправке формы, проверяем, если поле email-а пустое, а в нашем сгенериванном есть email, значит попросту перезаписываем оригинальное поле, что типа:
$_POST['email'] = $_POST['email_some-hash-code'];
иначе ничего не делаем.
5) соотвественно боты будут писать email в скрытое (оригинальное) поле, и таким образом будут палиться.

А комментарии, мы защитили, исходя из соображений, что у всех, важных нам, пользователей включен JS, поэтому опять же заводили скрытое поле, в которое писали, на клиенте, какой-нибудь хэш, например тот же ip. Соответственно если этого поля нет или оно не верное, значит спам.

Разумеется есть поток, ручного спама, но он составляет, только 0,2% от всех попыток оставить коммент, поэтому такой спам, легко фильтруется модератором.

Я написал javascript который после получения фокуса полем email, подменял его на другое поле. Дальше проверял пришло ли мое поле, на стороне сервера и если оно не пришло, то решал что это робот.

В файле: www/styles/prosilver/template/ucp_register.html, в конец файла, после формы, но перед

Код: Выделить всё

<!-- INCLUDE overall_footer.html -->
добавляем такой скрипт

Код: Выделить всё

</form>
 
<script>
    var el = document.getElementById('email');
    el.onfocus = function(){
        el.style.display = 'none';
        var container = document.createElement('div');
        container.innerHTML = '<input name="emai1" id="emai1" type="text" tabindex="2" size="25" maxlength="100" value="{EMAIL}" class="inputbox autowidth" autocomplete="off">';
        el.parentNode.appendChild(container);
        document.getElementById('emai1').focus();
        document.forms["register"].onsubmit = function(){
            el.parentNode.removeChild(el);
        }
    };
</script>
 
 
<!-- INCLUDE overall_footer.html -->
Если присмотреться, то вы заметите, что при получении фокуса полем email (L на конце), создается поле emai1 (Единица на конце) и оригинальное поле подменяется на него.

Далее, в файле www/ucp.php в самое начало я добавил проверку

Код: Выделить всё

//include 'disable-cache.php';
 
if (!empty($_GET['mode']) && $_GET['mode'] =='register' && !empty($_POST['submit'])) {
    if (empty($_POST['emai1'])) {
        $_POST['email'] = '';
    } else {
        $_POST['email'] = $_POST['emai1'];
    }
}
 
Тут проверяется следующее, если была попытка регистрации, то проверяется наличие поля $_POST['emai1'] (Единица на конце) и если оно пришло, то его содержимое копируется в оригинальную переменную $_POST['email'] (L на конце). В противном случае, скрипт очищает любые данные переданные в $_POST['email'].

Усложнять сильнее можно развивая эту идею, но я не стал этого делать, т.к. только это отсеяло всех авто-ботов.

В коде выше вы можете увидеть закомментированную строку с подключением файла disable-cache.php, вот его содержимое

Код: Выделить всё

<?php

//die('disabled on backend');

function rrmdir($dir) {
   if (is_dir($dir)) {
     $objects = scandir($dir);
     foreach ($objects as $object) {
       if ($object != "." && $object != "..") {
         if (is_dir($dir."/".$object))
           rrmdir($dir."/".$object);
         else
           unlink($dir."/".$object);
       }
     }
     rmdir($dir);
   }
 }

 rrmdir(dirname(__FILE__).'/cache/twig');
 mkdir(dirname(__FILE__).'/cache/twig',0755);

 foreach(glob(dirname(__FILE__).'/cache/*.php') as $file) unlink($file);
Его смысл, в том, чтобы в момент отладки при каждой перезагрузки страницы очищать кеш файлов шаблонов. Если будете использовать мой код, то вам его не надо подключать. Если будете модернизировать js-код в шаблоне, то помните, что шаблоны кешируются шаблонизатором и в момент отладки, надо чистить кеш. Сделать это можно или руками, или с помощью подключения этого файла.

Теперь, вернемся к тому, что же делать со спамом который постят вручную. Способов достаточно много, я выбрал способ с быстрым оповещением о любой активности на форуме, чтобы моментально узнавать не только о спаме, но и том что кто-то просит поддержки. Написал такой скрипт:

Код: Выделить всё

<?php

    class Cfg {
        const DB_HOST = 'localhost';
        const DB_USER = '<ПОЛЬЗОВАТЕЛЬ-БД>';
        const DB_PASS = '<ПАРОЛЬ-БД>';
        const DB_NAME = '<ИМЯ-БД>';

        const EMAIL_FROM = 'It-Rem Forum <no-reply@forum.it-rem.ru>';
        const EMAIL_REPLY_TO = 'no-reply@forum.it-rem.ru';
        const EMAIL_TO = 'new@forum.it-rem.ru';
        const EMAIL_SUBJECT = 'New posts at forum.it-rem.ru';

        const POSTS_PER_MAIL_LIMIT = 50; // per email limit

        const ADMIN_TIMEZONE_OFFSET = 3; // in hours from UTC

        const PHPBB_TBL_PREFIX = 'phpbb_';
        const PHPBB_BASE_URL = 'http://forum.it-rem.ru'; // base url of forum, ex: http://forum.site.com

        const ALLOW_IGNORE_USERS = true; // that option allow ignore messages from specified users (admins, moderators, etc..)

        public static function getIgnoreUsersSql() {
            $ignoreUsersWithEmails = [ // specify here emails that we should ignore
                'admin@forum.it-rem.ru',
            ];

            if (!self::ALLOW_IGNORE_USERS OR empty($ignoreUsersWithEmails)) return '';

            return ' AND `users`.`user_email`!="'
                   .implode('" AND `users`.`user_email`!="', $ignoreUsersWithEmails)
                   .'"'
            ;
        }

        public static function getVar($var, $default=null) {
            $values = [];
            $storagePath = dirname(__FILE__).'/storage.json';
            if (file_exists($storagePath)) {
                $values = json_decode(file_get_contents($storagePath), true);
                if (!is_array($values)) $values =[];
            }
            return isset($values[$var]) ? $values[$var] : $default;
        }

        public static function setVar($var, $val) {
            $values = [];
            $storagePath = dirname(__FILE__).'/storage.json';
            if (file_exists($storagePath)) {
                $values = json_decode(file_get_contents($storagePath), true);
                if (!is_array($values)) $values =[];
            }
            $values[$var] = $val;
            file_put_contents($storagePath, json_encode($values));
        }

    }

    class DbConnection {
        private $host = '';
        private $user = '';
        private $pass = '';
        private $dbName = '';
        private $defaultCharset = 'utf8';
        private $dbLink = null;

        public function __construct($host, $user, $pass, $dbName) {
            $this->host = $host;
            $this->user = $user;
            $this->pass = $pass;
            $this->dbName = $dbName;
        }

        public function getConnection($force=false){
            if (is_null($this->dbLink) || $force) {
                $this->dbLink = new mysqli($this->host, $this->user, $this->pass, $this->dbName);

                if ($this->dbLink->connect_errno) {
                    echo "MySQL Error: Failed to make a MySQL connection, here is why: \n";
                    echo "Errno: " . $this->dbLink->connect_errno . "\n";
                    echo "Error: " . $this->dbLink->connect_error . "\n";
                    exit;
                }
            }

            if (!$this->dbLink->set_charset($this->defaultCharset)) {
                echo "MySQL Error loading character set $charset\n";
                echo "Errno: " . $this->dbLink->errno . "\n";
                echo "Error: " . $this->dbLink->error . "\n";
                exit;
            }

            return $this->dbLink;
        }

        public function setCharset($charset='utf8') {
            $db = $this->getConnection();
            if (!$db->set_charset($charset)){
                echo "MySQL Error loading character set $charset\n";
                echo "Errno: " . $db->errno . "\n";
                echo "Error: " . $db->error . "\n";
                exit;
            }
        }

        public function query($sql){
            $db = $this->getConnection();
            if (!$result = $db->query($sql)) {
                echo "MySQL Error: Our query failed to execute and here is why: \n";
                echo "Query: " . $sql . "\n";
                echo "Errno: " . $db->errno . "\n";
                echo "Error: " . $db->error . "\n";
                exit;
            }
            return $result;
        }

        public function select($sql){
            $result = $this->query($sql);
            $ret = [];
            if ($result->num_rows !== 0) {
                while ($row = $result->fetch_assoc()) {
                    $ret[] = $row;
                }
            }
            $result->free();
            return $ret;
        }

        public function close(){
            if (!is_null($this->dbLink)) {
                $this->dbLink->close();
            }
        }

        public function __destruct(){
            $this->close();
        }
    }

    $db = new DbConnection(Cfg::DB_HOST, Cfg::DB_USER, Cfg::DB_PASS, Cfg::DB_NAME);

    $lastSentPostId = Cfg::getVar('lastSentPostId',0);

    $sql = 'SELECT
                `posts`.`post_id` AS `post_id`,
                `posts`.`forum_id` AS `forum_id`,
                `posts`.`topic_id` AS `topic_id`,

                `posts`.`post_time` AS `time`,
                `posts`.`post_subject` AS `subject`,
                `posts`.`post_text` AS `text`,

                `users`.`username` AS `author`,
                `users`.`user_email` AS `email`
            FROM
                `'.Cfg::PHPBB_TBL_PREFIX.'posts` AS `posts`
            LEFT JOIN
                `'.Cfg::PHPBB_TBL_PREFIX.'users` AS `users`
            ON
                `posts`.`poster_id` = `users`.`user_id`
            WHERE
               `posts`.`post_id`>'.intval($lastSentPostId).'
               '.Cfg::getIgnoreUsersSql().'
            ORDER BY
                `posts`.`post_id` ASC
            LIMIT '.Cfg::POSTS_PER_MAIL_LIMIT.'
    ';

    $newPosts = $db->select($sql);

    if (!$newPosts) {
        echo 'No new posts from last check';
        exit;
    }

    $msgTemplate = '
    <html>
        <head>
            <style>
                div.text {
                    background-color: #F5F5F5;
                    font-size:13px;
                    margin:15px 0px;
                    max-width:600px;
                    border: 1px solid #D5D5D5;
                    border-left: 2px solid silver;
                    border-radius:5px;
                    padding:15px;
                }
                blockquote {
                    background-color: #d5d5d5;
                    border: 1px solid #c5c5c5;
                    padding: 5px;
                    color:black;
                    margin:0px 0px 0px 15px;
                    border-radius:2px;
                }
                blockquote blockquote {
                    background-color: #e4e4e4;
                    margin: 0.5em 1px 0 15px;
                }
                blockquote blockquote blockquote {
                    background-color: #f4f4f4;
                }
                .bbcode_tags {color:silver;}
            </style>
        </head>
        <body style="font-family:Courier; font-size:14px;">
            {body}
        </body>
    </html>
    ';

    $msgPostTemplate = '
        <strong>User</strong>: {author} &lt;{email}&gt;<br>
        <strong>Subject</strong>: {subject}<br>
        <strong>Date</strong>: {date}<br>
        <strong>Text</strong>:
            <div class="text">
                {text}
            </div>
        <strong>Manage</strong>: <a href="{forum_url}/viewtopic.php?f={forum_id}&t={topic_id}">Open topic</a><br>

        <br><hr><br>
    ';

    $msgBody = '';

    foreach($newPosts as $post) {

        $text = $post['text'];

        // replace quotes bbcode
        $text = preg_replace('~\[quote[^:\]]*:[^\]]+\]~','<blockquote>',$text);
        $text = preg_replace('~\[/quote:[^\]]+\]~','</blockquote>',$text);

        // replace code bbcode
        $text = preg_replace('~\[code[^:\]]*:[^\]]+\]~','<code>',$text);
        $text = preg_replace('~\[/code:[^\]]+\]~','</code>',$text);

        // replace b,i,u bbcodes
        $text = preg_replace('~\[(b|u|i):[^\]]+\]~','<$1>',$text);
        $text = preg_replace('~\[/(b|u|i):[^\]]+\]~','</$1>',$text);

        // replace urls bbcode
        $text = preg_replace('~\[url:[^\]]+\]([^\[]+)(?=\[)~','<a href="$1">$1',$text);
        $text = preg_replace('~\[url=([^:\]]+):[^\]]+\]([^\[]+)(?=\[)~','<a href="$1">$2',$text);
        $text = preg_replace('~\[/url:[^\]]+\]~','</a>',$text);

        // replace video bbcode
        $text = preg_replace('~\[\s?video:[^\]]+\]([^\[]+)(?=\[)~','<a href="$1">$1',$text);
        $text = preg_replace('~\[/video:[^\]]+\]~','</a>',$text);

        // replace other bbcode
        $text = preg_replace('~\[([^:\]]+):[^\]]+\]~','<span class="bbcode_tags">[$1]</span>',$text);

        // clean up double spaces and double line endings. Convert line ending to new lines (br)

        // $text = preg_replace('~(\s)\s+~','$1',$text);
        $text = str_replace("\n",'<br>', trim($text));

        // Fix unclosed tags
        $dom = new DOMDocument();
        $dom->loadHTML('<?xml encoding="utf-8" ?>'.$text, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
        $text = $dom->saveHTML();

        $msgBody .=  str_replace(
            [
                '{author}',
                '{email}',
                '{subject}',
                '{date}',
                '{text}',
                '{forum_url}',
                '{forum_id}',
                '{topic_id}',
            ], [
                htmlspecialchars($post['author']),
                $post['email'] ? htmlspecialchars($post['email']) : 'undefined@email',
                $post['subject'],
                date('d.m.y H:i',strtotime('+'.Cfg::ADMIN_TIMEZONE_OFFSET.' hours',$post['time'])),
                $text,
                Cfg::PHPBB_BASE_URL,
                intval($post['forum_id']),
                intval($post['topic_id']),
            ],
            $msgPostTemplate
        );
    }

    $emailMessage = str_replace('{body}',$msgBody, $msgTemplate);

    $emailheaders = "From: " . Cfg::EMAIL_FROM . "\r\n";
    $emailheaders .= "Reply-To: ". Cfg::EMAIL_REPLY_TO . "\r\n";
    $emailheaders .= "MIME-Version: 1.0\r\n";
    $emailheaders .= "Content-Type: text/html; charset=UTF-8\r\n";

    if (mail(Cfg::EMAIL_TO, Cfg::EMAIL_SUBJECT, $emailMessage, $emailheaders)) {
        Cfg::setVar('lastSentPostId', $newPosts[count($newPosts)-1]['post_id']);
        echo 'Message about '.count($newPosts).' new post was succesfully sent'.PHP_EOL;
    } else {
        echo 'Error: Mail not sent'.PHP_EOL;
    }
Этот код надо сохранить в какую-нибудь отдельную папку, напримеру www/notifier/cron.php и добавить в Cron, на запуск каждые 15 минут. В результате на указанный email будт приходить в виде писем все опубликованные посты. Это позволяет видеть сразу что и в каком форуме опубликовали и вовремя отреагировать - дать ответ или забанить спамера. Для форумов у которых очень много ежедневных сообщений это всего скорее не подойдет, в таком виде, надо дописать и ввести какие-нибудь ограничения. А вот для небольших сообществ с одним можератором будет самое то.

P.S. Весь описанный в этой статье код был актуален для phpBB 3.1.9, в новых версиях код возможно но не факт потребует актуализации.

Отправлено спустя 2 минуты 30 секунд:
Источник:
https://www.it-rem.ru/izbavlyaemsya-ot- ... phpbb.html
https://www.it-rem.ru/chistka-i-moderir ... phpbb.html
https://toloka.listbb.ru, форум создан т.к. яндекс закрыл официальный форум.
http://wlife.spb.ru женско-семейный форум


desoto53
Сообщения: 30
Зарегистрирован: 15 сен 2019, 18:48
Благодарил (а): 2 раза
Поблагодарили: 1 раз
Контактная информация:

Чистка и модерирование и борьба со спамом на phpBB

Сообщение desoto53 » 16 май 2021, 20:41

foxss писал(а):
14 май 2021, 23:34
Решил определиться что попадает 100% под спам:
- Посты в которых 0 сообщений
- Посты не от админа
- Посты не содержащие ключевых слов
Описанное выше - это точно форум?

Ответить