Commit 852b48d7 authored by theo lem's avatar theo lem
Browse files

WIP : Spam protection modules

parent 6f46c3f5
......@@ -10,9 +10,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added
* BOTCHA module to fix the possible bypass of CAPTCHA Riddler.
* IP module to log user IPs.
* Anti-spam measures that unpublish forms containing suspicious words in title, or don't have any webform component.
* Automatic deletion of forms after a period of time.
* Views : /possible-spam, displays unpublished forms from above.
* Anti-spam module `framaforms_spam` that put forms in quarantine containing suspicious words in title, or don't have any webform component.
* VBO (Views Bulk Operations) to put nodes inside quarantine and to take them out.
* Automatic deletion of forms in quarantine after a period of time.
* Views : /suspicious, displays unpublished forms from above.
* Views : /quarantine, displays forms in quarantine.
## [1.0.1] 2020/07/15
......
......@@ -52,12 +52,10 @@ function framaforms_install () {
framaforms_set_variables_pages();
$now = date('Y/m/d/ h:m:s', time());
variable_set('framaforms_cron_last', new DateTime()); // the last framforms cron run
variable_set('framaforms_cron_frequency', '1 second');
variable_set('framaforms_cron_frequency', '1 hour');
variable_set('framaforms_expiration_period_default_value', 26); // default creation-expiration time interval
variable_set('framaforms_deletion_period_value', 9); // default expiration-deletion time interval
variable_set('framaforms_notification_period_value', 2); // default notification-expiration time interval
variable_set('framaforms_suspicious_form_deletion', '1 week'); // default deletion period for possible spam
variable_set('site_contact_url', 'https://contact.framasoft.org/#framaforms');
/* various other custom manipulation */
......
......@@ -565,30 +565,6 @@ function framaforms_cron(){
make_forms_expire(); // Look in node table for nodes about to expire, and notify the users.
update_modified_nodes(); // Look in the node table for nodes whose expiration date got changed by the user
delete_expired_nodes(); // Delete expired nodes after a period of inactivity
framaforms_delete_possible_spam();
}
/**
* Deletes from database forms that were considered as potential spam (unpublished)
* and that were not changed for a period of time
*
* @return void
*/
function framaforms_delete_possible_spam () {
$max_possible_spam_lifetime = variable_get('framaforms_suspicious_form_deletion');
$query = "SELECT nid
FROM node
WHERE status = 0
AND type = 'form1'
AND to_timestamp(changed) < NOW() - interval'{$max_possible_spam_lifetime}'";
try{
$results = db_query($query)->fetchCol();
node_delete_multiple($results);
}
catch(PDOException $e){
watchdog("framaforms", "Error deletion potential spam : ".$e);
}
}
/**
......@@ -601,63 +577,4 @@ function framaforms_node_delete ($node) {
->condition('source', 'node/'.$node->nid)
->execute();
}
}
/**
* Implements hook_node_insert.
*
* @param $node : the node that was published
* @return void
*/
function framaforms_node_presave($node) {
// Check newly created form nodes for webform components. If no webfom components were inserted, unpublish the form.
// Only checking nodes where nid != NULL because we only want to consider fully created nodes.
if($node->type == 'form1' && $node->nid != NULL && $node->webform['components'] == array()) {
$node->status = 0; // Unpublish the form node - it won't be accessibly to the public.
drupal_set_message(t("Your form was created empty. It is considered as spam, and will soon be deleted automatically. Please insert at least one form field to prove this content is legitimate."), 'warning');
}
// Republish nodes that were previously considered as spam where components were added
if($node->status == 0 && $node->webform['components'] != array()){
$node->status = 1; // Publish the content
drupal_set_message(t("Your form is now considered as legitimate, and is accessible publicly."), 'status');
}
}
/**
* Implements hook_node_insert.
* Check title for suspicious words in new form titles.
* @param [type] $node
* @return void
*/
function framaforms_node_insert ($node) {
// Regex containing suspicious words
$suspicious_words = "%(free|watch|online|movie|pelicula|completa|streaming|1080|fortnite|tiktok|mulan|poker)%";
if($node->type == 'form1' && $node->nid != NULL && preg_match($suspicious_words, strtolower($node->title)) != 0){
$node->status = 0; // Unpublish the form node - it won't be accessibly to the public.
drupal_set_message(t("Your form contains suspicious words and is therefore considered as spam. The site's administrators will review it shortly."), 'warning');
}
}
/**
* Implements hook_views_api
*
* @return void
*/
function framaforms_views_api () {
return array(
'api' => 3,
);
}
/**
* Implements hook_views_default_views
*
* @return void
*/
function framaforms_views_default_views () {
module_load_include('inc', 'framaforms', 'includes/framaforms.views');
$views = array();
$views['possible-spam'] = return_possible_spam_view_definition();
return $views;
}
\ No newline at end of file
......@@ -195,7 +195,7 @@ function framaforms_config_form ($form, &$form_state) {
'#submit' => array('system_settings_form_submit'),
);
return $form;
}
}
/**
* Secondary callback for the "Generate pages" button
......
<?php
/**
* Returns the view definition for "Possible spam" (accessible at /possible-spam) :
* lists forms that were unpublished because considered as spam.
*
* @return void
*/
function return_possible_spam_view_definition () {
$view = new view();
$view->name = 'possible_spam';
$view->description = '';
$view->tag = 'default';
$view->base_table = 'node';
$view->human_name = 'Possible spam';
$view->core = 7;
$view->api_version = '3.0';
$view->disabled = FALSE; /* Edit this to true to make a default view disabled initially */
/* Display: Master */
$handler = $view->new_display('default', 'Master', 'default');
$handler->display->display_options['title'] = 'Possible spam';
$handler->display->display_options['use_more_always'] = FALSE;
$handler->display->display_options['use_more_text'] = 'plus';
$handler->display->display_options['access']['type'] = 'role';
$handler->display->display_options['access']['role'] = array(
3 => '3',
);
$handler->display->display_options['cache']['type'] = 'none';
$handler->display->display_options['query']['type'] = 'views_query';
$handler->display->display_options['exposed_form']['type'] = 'basic';
$handler->display->display_options['exposed_form']['options']['submit_button'] = 'Appliquer';
$handler->display->display_options['exposed_form']['options']['reset_button_label'] = 'Réinitialiser';
$handler->display->display_options['exposed_form']['options']['exposed_sorts_label'] = 'Trier par';
$handler->display->display_options['pager']['type'] = 'full';
$handler->display->display_options['pager']['options']['items_per_page'] = '50';
$handler->display->display_options['pager']['options']['expose']['items_per_page_label'] = 'Éléments par page';
$handler->display->display_options['pager']['options']['expose']['items_per_page_options_all_label'] = '- Tout -';
$handler->display->display_options['pager']['options']['expose']['offset_label'] = 'Décalage';
$handler->display->display_options['pager']['options']['tags']['first'] = '« premier';
$handler->display->display_options['pager']['options']['tags']['previous'] = '‹ précédent';
$handler->display->display_options['pager']['options']['tags']['next'] = 'suivant ›';
$handler->display->display_options['pager']['options']['tags']['last'] = 'dernier »';
$handler->display->display_options['style_plugin'] = 'table';
/* Champ: Opérations en masse : Contenu */
$handler->display->display_options['fields']['views_bulk_operations']['id'] = 'views_bulk_operations';
$handler->display->display_options['fields']['views_bulk_operations']['table'] = 'views_entity_node';
$handler->display->display_options['fields']['views_bulk_operations']['field'] = 'views_bulk_operations';
$handler->display->display_options['fields']['views_bulk_operations']['vbo_settings']['display_type'] = '0';
$handler->display->display_options['fields']['views_bulk_operations']['vbo_settings']['enable_select_all_pages'] = 1;
$handler->display->display_options['fields']['views_bulk_operations']['vbo_settings']['row_clickable'] = 1;
$handler->display->display_options['fields']['views_bulk_operations']['vbo_settings']['force_single'] = 0;
$handler->display->display_options['fields']['views_bulk_operations']['vbo_settings']['entity_load_capacity'] = '10';
$handler->display->display_options['fields']['views_bulk_operations']['vbo_operations'] = array(
'action::node_unpublish_action' => array(
'selected' => 1,
'postpone_processing' => 0,
'skip_confirmation' => 0,
'skip_permission_check' => 0,
'override_label' => 0,
'label' => '',
),
'action::views_bulk_operations_delete_item' => array(
'selected' => 1,
'postpone_processing' => 0,
'skip_confirmation' => 0,
'skip_permission_check' => 0,
'override_label' => 0,
'label' => '',
'settings' => array(
'log' => 0,
),
),
);
/* Champ: Contenu : uid de l'auteur */
$handler->display->display_options['fields']['uid']['id'] = 'uid';
$handler->display->display_options['fields']['uid']['table'] = 'node';
$handler->display->display_options['fields']['uid']['field'] = 'uid';
$handler->display->display_options['fields']['uid']['label'] = 'Auteur';
/* Champ: Contenu : Titre */
$handler->display->display_options['fields']['title']['id'] = 'title';
$handler->display->display_options['fields']['title']['table'] = 'node';
$handler->display->display_options['fields']['title']['field'] = 'title';
$handler->display->display_options['fields']['title']['alter']['word_boundary'] = FALSE;
$handler->display->display_options['fields']['title']['alter']['ellipsis'] = FALSE;
/* Champ: Contenu : Date de publication */
$handler->display->display_options['fields']['created']['id'] = 'created';
$handler->display->display_options['fields']['created']['table'] = 'node';
$handler->display->display_options['fields']['created']['field'] = 'created';
$handler->display->display_options['fields']['created']['date_format'] = 'long';
$handler->display->display_options['fields']['created']['second_date_format'] = 'long';
$handler->display->display_options['fields']['created']['format_date_sql'] = 0;
/* Champ: Contenu : Chemin */
$handler->display->display_options['fields']['path']['id'] = 'path';
$handler->display->display_options['fields']['path']['table'] = 'node';
$handler->display->display_options['fields']['path']['field'] = 'path';
$handler->display->display_options['fields']['path']['label'] = 'Modifier';
$handler->display->display_options['fields']['path']['alter']['alter_text'] = TRUE;
$handler->display->display_options['fields']['path']['alter']['text'] = '<a href="[path]/edit">Modifier</a>';
$handler->display->display_options['fields']['path']['alter']['path'] = '[path]/edit';
/* Critère de tri: Contenu : Date de publication */
$handler->display->display_options['sorts']['created']['id'] = 'created';
$handler->display->display_options['sorts']['created']['table'] = 'node';
$handler->display->display_options['sorts']['created']['field'] = 'created';
$handler->display->display_options['sorts']['created']['order'] = 'DESC';
/* Critère de filtrage: Contenu : Published status */
$handler->display->display_options['filters']['status']['id'] = 'status';
$handler->display->display_options['filters']['status']['table'] = 'node';
$handler->display->display_options['filters']['status']['field'] = 'status';
$handler->display->display_options['filters']['status']['value'] = '0';
$handler->display->display_options['filters']['status']['group'] = 1;
$handler->display->display_options['filters']['status']['expose']['operator'] = FALSE;
/* Critère de filtrage: Contenu : Type */
$handler->display->display_options['filters']['type']['id'] = 'type';
$handler->display->display_options['filters']['type']['table'] = 'node';
$handler->display->display_options['filters']['type']['field'] = 'type';
$handler->display->display_options['filters']['type']['value'] = array(
'form1' => 'form1',
);
/* Display: Page */
$handler = $view->new_display('page', 'Page', 'page');
$handler->display->display_options['path'] = 'possible-spam';
$translatables['possible_spam'] = array(
t('Master'),
t('Possible spam'),
t('plus'),
t('Appliquer'),
t('Réinitialiser'),
t('Trier par'),
t('Asc'),
t('Desc'),
t('Éléments par page'),
t('- Tout -'),
t('Décalage'),
t('« premier'),
t('‹ précédent'),
t('suivant ›'),
t('dernier »'),
t('Contenu'),
t('- Choisir une opération -'),
t('Auteur'),
t('Titre'),
t('Date de publication'),
t('Modifier'),
t('<a href="[path]/edit">Modifier</a>'),
t('Page'),
);
return $view;
}
\ No newline at end of file
name = Framaforms Spam Protection
description = Features for automatically detect and quarantine spam forms in Framaforms.
core = 7.x
php = 7.3
; Configuration menu
configure = admin/config/system/framaforms-spam-protection
; Information added by drupal.org packaging script on 2016-10-25
version = "7.x-1.0"
core = "7.x"
project = "framaforms_spam"
datestamp = "1600264560"
\ No newline at end of file
<?php
/**
* @file
* Framaforms module install.
*/
/**
* Implements hook_schema();
*/
function framaforms_spam_schema(){
$schema['framaforms_spam_quarantine'] = array(
'description' => 'Table containing forms that are put into quarantine.',
'fields' => array(
'nid' => array(
'description' => 'NID of the webform (foreign key)',
'type' => 'int',
'not null' => TRUE,
),
'uid' => array(
'description' => 'Uid of the original author',
'type' => 'int',
'not null' => TRUE,
),
'date_quarantine' => array(
'description' => 'The date the form was put into quarantine',
'type' => 'int',
'not null' => TRUE,
)
),
'unique keys' => array(
'nid' => array(
'nid'
)
),
'foreign key'=>array(
'fk_name_node' => array(
'table'=>'node',
'column'=>array('nid'=>'nid')
),
),
);
$schema['framaforms_spam_allowlist'] = array(
'description' => 'Table containing forms are allowlisted from the quarantine.',
'fields' => array(
'nid' => array(
'description' => 'NID of the webform (foreign key)',
'type' => 'int',
'not null' => TRUE,
),
),
'unique keys' => array(
'nid' => array(
'nid'
)
),
'foreign key'=>array(
'fk_name_node' => array(
'table'=>'node',
'column'=>array('nid'=>'nid')
),
),
);
return $schema;
}
function framaforms_spam_install () {
variable_set('framaforms_spam_quarantine_deletion', '1 week'); // default deletion period for possible spam
variable_set('framaforms_spam_cron_last', new DateTime()); // the last framforms cron run
variable_set('framaforms_spam_cron_frequency', '1 hour');
variable_set('framaforms_spam_forbidden_words', 'free, watch, online, movie, pelicula, completa, streaming, 1080, fortnite, tiktok, mulan, poker');
return;
}
function framaforms_spam_uninstall () {
return;
}
\ No newline at end of file
<?php
function framaforms_spam_menu () {
$items = array();
$items['admin/config/system/framaforms_spam'] = array(
'title' => 'Configure Framaforms Spam Protection',
'description' => t('Configuration for Framaforms Spam Protection'),
'page callback' => 'drupal_get_form',
'page arguments' => array('framaforms_spam_config_form'),
'access arguments' => array('access administration pages'),
'type' => MENU_CALLBACK,
);
return $items;
}
function framaforms_spam_config_form ($form, &$form_state) {
$form['framaforms_spam_forbidden_words'] = array(
'#type' => 'textfield',
'#title' => t('Forbidden words'),
'#default_value' => variable_get('framaforms_spam_forbidden_words'),
'#description' => t('Words that are considered suspicious when found in new form titles. You can separate forms with commas (,).'),
'#required' => FALSE,
);
$form['submit'] = array(
'#type' => 'submit',
'#value' => t('Submit'),
'#submit' => array('system_settings_form_submit'),
);
return $form;
}
function framaforms_spam_cron () {
/**
* Parameter for adjusting cron frequency.
*/
$framaforms_spam_cron_last = variable_get('framaforms_spam_cron_last'); // the last time the cron task was run.
$cron_limit = new DateTime();
$cron_limit->setTimestamp($framaforms_spam_cron_last);
$cron_limit->modify('+'.variable_get('framaforms_spam_cron_frequency'));
$now = new DateTime();
// if the cron was executed less than 24h ago, do nothing.
if($now < $cron_limit){
return;
}
// else, proceed to cron.
watchdog('framaforms_spam', t("Launching cron task."));
variable_set('framaforms_spam_cron_last', $now->getTimestamp());
framaforms_spam_delete_quarantine();
}
/**
* Deletes from database forms that were considered as potential spam (unpublished)
* and that were not changed for a period of time
*
* @return void
*/
function framaforms_spam_delete_quarantine () {
$max_possible_spam_lifetime = variable_get('framaforms_spam_quarantine_deletion');
// get old quarantined forms
$query = "SELECT nid
FROM framaforms_spam_quarantine
WHERE to_timestamp(date_quarantine) < NOW() - interval'{$max_possible_spam_lifetime}'";
try{
$results = db_query($query)->fetchCol();
// delete them along with their references and attached files
node_delete_multiple($results);
// delete forms from framaforms_spam tables : _allowlist and _quarantine
foreach($results as $result){
db_delete('framaforms_spam_quarantine')
->condition('nid', $result, '=')
->execute();
db_delete('framaforms_spam_allowlist')
->condition('nid', $result, '=')
->execute();
}
}
catch(PDOException $e){
watchdog("framaforms", "Error deleting potential spam : ".$e);
}
}
/**
* Implements hook_node_insert.
*
* @param $node : the node that was published
* @return void
*/
function framaforms_spam_node_presave($node) {
if(framaforms_spam_node_is_allowlisted($node->nid)){
return;
}
if(framaforms_spam_node_is_in_quarantine($node->nid)){
$node->status = 0;
return;
}
// Detect words that should be considered as suspicious
// TODO : make this configurable through admin interface
$suspicious_words = "%(free|watch|online|movie|pelicula|completa|streaming|1080|fortnite|tiktok|mulan|poker)%";
if($node->type == 'form1' && $node->nid != NULL && preg_match($suspicious_words, strtolower($node->title)) != 0){
framaforms_spam_put_node_into_quarantine($node);
drupal_set_message(t("Your form contains suspicious words and is therefore considered as spam. The site's administrators will review it shortly."), 'warning');
return;
}
// Check newly created form nodes for webform components. If no webfom components were inserted, put node into quarantine.
// Only checking nodes where nid != NULL because we only want to consider fully created nodes.
if($node->type == 'form1' && $node->nid != NULL && $node->webform['components'] == array()) {
framaforms_spam_put_node_into_quarantine($node);
return;
}
return;
}
/**
* Returns TRUE if the passed string contains one of the forbidden words stored as global variable
*
* @param $str
* @return void
*/
function framaforms_spam_form_contains_forbidden_word ($str) {
$forbidden_words = variable_get('framaforms_spam_forbidden_words');
watchdog('framaforms_spam', $forbidden_words);
$regex = '%('
. str_replace(', ', '|', $forbidden_words)
. ')%';
return preg_match($regex, strtolower($str)) ? TRUE : FALSE;
}
/**
* Returns TRUE if a given node is in quarantine
*
* @param [type] $nid
* @return void
*/
function framaforms_spam_node_is_in_quarantine ($nid) {
try{
$query = db_select('framaforms_spam_quarantine', 'f')
->fields('f', array('nid'))
->condition('nid', $nid, '=')
->execute()
->fetchAssoc();
// If no results were returned, then the form is not in quarantine.
if($query == NULL){
return FALSE;
}
}
catch(PDOException $e){
watchdog('Error checking framaforms_spam_quarantine table : ' .$e);
}
return TRUE;
}
/**
* Returns TRUE if a given node was allowliste from quarantine
*
* @param [type] $nid
* @return void
*/
function framaforms_spam_node_is_allowlisted ($nid) {
try{
$query = db_select('framaforms_spam_allowlist', 'f')
->fields('f', array('nid'))
->condition('nid', $nid, '=')
->execute()
->fetchAssoc();
// If no results were returned, then the form is not in the allowlist.
if($query == NULL){
return FALSE;
}
}
catch(PDOException $e){
watchdog('Error checking framaforms_spam_allowlist table : ' .$e);
}
return TRUE;
}
/**
* Inserts form into framaforms_spam_allowlist
* @param [type] $nid
* @return void
*/
function framaforms_spam_allowlist_node ($nid) {
// Check if the form actually exists
$node = node_load($nid);
if(!node){
watchdog('framaforms_spam', t('Trying to put into quarantine unexisting form #'). $nid);
return;
}
try{
db_insert('framaforms_spam_allowlist')
->fields(array(
'nid' => $nid,
))
->execute();
}
catch(PDOException $e){
watchdog('framaforms_spam', 'Error trying to put insert node into allowlist table : '.$e);
}
}
/**
* Inserts form into framaforms_spam_quarantine and unpublishes it.
*