Skip to content

Instantly share code, notes, and snippets.

@AmauryCarrade
Last active December 20, 2015 14:58
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save AmauryCarrade/6150438 to your computer and use it in GitHub Desktop.
Save AmauryCarrade/6150438 to your computer and use it in GitHub Desktop.
A calculation-based anti-spam for web forms, written in PHP. The documentation is available under the first file.
<?php
if(session_id() == '') {
session_start();
}
/**
* This class generates a calculation to prevent the spam on forms.
*
* @author Amaury Carrade
*/
/**
* Copyright 2013 Amaury Carrade.
*
* This software is provided 'as-is', without any express or implied warranty. In no event will the
* authors be held liable for any damages arising from the use of this software.
*
* Permission is granted to anyone to use this software for any purpose, including commercial
* applications, and to alter it and redistribute it freely, subject to the following restrictions:
*
* 1. The origin of this software must not be misrepresented; you must not claim that you wrote the
* original software. If you use this software in a product, an acknowledgment in the product
* documentation would be appreciated but is not required.
* 2. Altered source versions must be plainly marked as such, and must not be misrepresented as being
* the original software.
* 3. This notice may not be removed or altered from any source distribution.
*/
class CalculusAntiSpam {
const DIFFICULTY_EASY = 1;
const DIFFICULTY_MEDIUM = 2;
const DIFFICULTY_HARD = 3;
static $defaults = array();
static $config = array(); // Needs to be static because the method that sets this is a static method.
/**
* Initialize the default values.
*/
static protected function initDefaults() {
if(self::$defaults != array()) return;
$numbers = array('', 'un', 'deux', 'trois', 'quatre', 'cinq', 'six', 'sept', 'huit', 'neuf', 'dix', 'onze', 'douze', 'treize', 'quatorze', 'quinze', 'seize', 'dix-sept', 'dix-huit', 'dix-neuf', 'vingt', 'vingt-et-un');
self::$defaults = array('difficulty' => self::DIFFICULTY_MEDIUM,
'sessionNamespace' => 'CalculusAntiSpam',
'numbers' => $numbers,
);
}
/**
* Saves a configuration in the class (for defaults) or in the object (for current config).
*
* @param array $config
* @param string $target "default" or "current".
* @see self::setDefaults for explanations on $config.
*/
static protected function setInternalConfig(array $config, $target) {
self::initDefaults();
$newConfig = self::$defaults; // Config is always based on the default config.
foreach ($config as $key => $value) {
switch ($key) {
case 'difficulty':
if(in_array($value, array(self::DIFFICULTY_EASY,
self::DIFFICULTY_MEDIUM,
self::DIFFICULTY_HARD))) {
$newConfig['difficulty'] = $value;
}
break;
case 'sessionNamespace':
$newConfig['sessionNamespace'] = (string) $value;
break;
case 'numbers':
if(is_array($value) AND count($value) == 22) {
$newConfig['numbers'] = $value;
}
break;
case 'logfile':
$newConfig['logfile'] == $value;
}
}
if($target == 'default') {
self::$defaults = $newConfig;
}
else {
self::$config = $newConfig;
}
}
/**
* Sets the default configuration.
*
* Allowed options are:
*
* - difficulty: self::DIFFICULTY_EASY, self::DIFFICULTY_MEDIUM or self::DIFFICULTY_HARD
* (see constructor for explanations on difficulty levels);
*
* - sessionNamespace: the session used will be $_SESSION[sessionNamespace];
*
* - numbers: an array with the first item empty, and the following items filled with the
* numbers from one to twenty one.
* Ex: array('', 'one', 'two', ..., 'twenty one').
* Defaults: numbers in french.
*
* - logfile: if you want to be logged the fails, specify here a file. If not, sets to NULL.
* The file must be writable. If it is not, no error will be thrown, and obviously, the log
* will not be saved.
* The log includes the timestamp, the IP, the calculation, the answer, the difficulty level
* and the test ID (see self::generate() for this one).
* » Tip: you can use fail2ban with this log.
*
* @param array $defaults
*/
static public function setDefaults(array $defaults) {
self::setInternalConfig($defaults, 'default');
}
/**
* Sets the current configuration.
*
* @see self::setDefaults() for allowed options.
* @param array $config
*/
public function setConfig(array $config) {
self::setInternalConfig($config, 'current');
}
/**
* Initialize the object.
*
*
* Difficulty explained
* --------------------
*
* - EASY: the numbers are between 1 and 8 (between 2 and 8 if multiplication);
* the numbers are ordered, the highest is the first in the operation;
* no negative numbers.
*
* - MEDIUM: the numbers are between 1 and 12 (between 2 and 10 if multiplication);
* the first number can be a negative one;
* the numbers are not ordered.
*
* - HARD: the numbers are between 1 and 21 (between 2 and 12 if multiplication);
* the numbers are not ordered;
* the second number can be a negative one too.
*
*
* @param integer $difficulty The difficulty level: self::DIFFICULTY_EASY,
* self::DIFFICULTY_MEDIUM or
* self::DIFFICULTY_HARD (see below).
* Default: self::DIFFICULTY_MEDIUM (or the default option defined with self::setDefaults()).
*
* @param string $sessionNamespace The session's namespace. Session will be stored in
* $_SESSION[$sessionNamespace].
* Default: "CalculusAntiSpam" (or the default option defined with self::setDefaults()).
*
* @param array $numbers An array with the first item empty, and the following items filled
* with the numbers from one to twenty one.
* Ex: array('', 'one', 'two', ..., 'twenty one').
* Defaults: numbers in french.
*
* @return void.
*/
public function __construct($difficulty = NULL, $numbers = NULL, $sessionNamespace = NULL) {
self::setInternalConfig(self::$defaults, 'current'); // Import config from defaults.
$userConfig = array();
if($difficulty != NULL) {
$userConfig['difficulty'] = $difficulty;
}
if($sessionNamespace != NULL) {
$userConfig['sessionNamespace'] = $sessionNamespace;
}
if($numbers != NULL) {
$userConfig['numbers'] = $numbers;
}
self::setInternalConfig($userConfig, 'current');
}
/**
* Generates the question and saves the answer to the session.
*
* @param string $testId An unique identifier. If you have more than one anti-spam in one page, use this to
* differentiate the different ones. If not, ignore this.
* Default: 'noSpam1'.
* @return string A string containing the calcul, written with the numbers saved in configuration.
*/
public function generate($testId = 'noSpam1') {
$numbers = self::$config['numbers'];
$operators = array(0 => '+',
1 => '&minus;',
2 => '&times;');
// Lower numbers for lower difficulties.
if(self::$config['difficulty'] == self::DIFFICULTY_MEDIUM) {
$numbers = array_slice($numbers, 0, 13); // From 1 to 12.
}
else if(self::$config['difficulty'] == self::DIFFICULTY_EASY) {
$numbers = array_slice($numbers, 0, 9); // From 1 to 8.
}
// Generates the operator
$operator = mt_rand(0, 2);
if($operator == 2) { // An easier calcul for the multiplication.
if(self::$config['difficulty'] <= self::DIFFICULTY_MEDIUM) {
// Numbers between one and ten only.
$numbers = array_slice($numbers, 0, 11);
}
else {
// Numbers between one and twelve only.
$numbers = array_slice($numbers, 0, 13);
}
}
// Generates two random numbers
if($operator != 2) {
$number1 = mt_rand(1, count($numbers) - 1); // Zero is excluded.
$number2 = mt_rand(1, count($numbers) - 1);
}
else {
$number1 = mt_rand(2, count($numbers) - 1); // Zero is excluded. Same for one (because it's a multiplication).
$number2 = mt_rand(2, count($numbers) - 1);
}
if(self::$config['difficulty'] == self::DIFFICULTY_EASY) {
// First number is greater than second.
if($number1 < $number2) {
// Intervert.
$number1 = $number1 + $number2;
$number2 = $number1 - $number2;
$number1 = $number1 - $number2;
}
}
$number1signText = $number2signText = NULL; // Used for the “−” sign before the text.
$number2TextBefore = $number2TextAfter = NULL; // Used for the parenthesis.
if(self::$config['difficulty'] >= self::DIFFICULTY_MEDIUM) {
// Random sign for the numbers
$number1sign = mt_rand(0, 1);
if($number1sign) {
$number1 = -$number1;
$number1signText = '&minus; ';
}
if(self::$config['difficulty'] == self::DIFFICULTY_HARD) {
$number2sign = mt_rand(0, 1);
if($number2sign) {
$number2 = -$number2;
$number2TextBefore = '(';
$number2signText = '&minus; ';
$number2TextAfter = ')';
}
}
}
// Does the calcul
$result = 0;
switch ($operator) {
case 0:
$result = $number1 + $number2;
break;
case 1:
$result = $number1 - $number2;
break;
case 2:
$result = $number1 * $number2;
break;
}
$text = $number1signText . $numbers[abs($number1)] . ' ' . $operators[$operator] . ' ' . $number2TextBefore . $number2signText . $numbers[abs($number2)] . $number2TextAfter;
$numbersText = str_replace('-', '&minus;', $number1) . ' ' . $operators[$operator] . ' ' . trim($number2TextBefore) . str_replace('-', '&minus;', $number2) . $number2TextAfter;
// Trim : “(−12)”, not “(− 12)”.
$_SESSION[self::$config['sessionNamespace']][$testId] = array('text' => $text,
'result' => $result,
'resultText' => str_replace('-', '&minus;', $result),
'numbersText' => $numbersText,
'difficulty' => self::$config['difficulty'],
);
return $text;
}
/**
* Checks if the form wasn't submitted by a robot.
*
* @param integer $userInput
* @param string $testId See self::generate().
*
* @return bool true if the form wasn't submitted by a robot. False else,
* or if the answer was not found in the session.
*/
public function check($userInput, $testId = 'noSpam1') {
if(!isset($_SESSION[self::$config['sessionNamespace']][$testId]['result'])) {
return false;
}
$userCalcul = (int) $userInput;
$success = ($userCalcul == (int) $_SESSION[self::$config['sessionNamespace']][$testId]['result']);
if(!$success && self::$config['logfile'] != NULL
&& is_readable(self::$config['logfile'])
&& is_writable(self::$config['logfile'])) { // Log.
$date = new DateTime();
$difficulty = 'easy';
if($_SESSION[self::$config['sessionNamespace']][$testId]['difficulty'] == self::DIFFICULTY_MEDIUM) {
$difficulty = 'medium';
}
else if($_SESSION[self::$config['sessionNamespace']][$testId]['difficulty'] == self::DIFFICULTY_HARD) {
$difficulty = 'hard';
}
$log = file_get_contents(self::$config['logfile']);
$log .= '[' . $date->format('Y-m-d H:i:s') . '] Spam check error from ' . $_SERVER['REMOTE_ADDR'] . ' with ' . $_SESSION[SNS]['spamCheck']['numbersText'] . ' = ' . $userCalcul . ' (correct answer is ' . $_SESSION[SNS]['spamCheck']['resultText'] . ') (difficulty: ' . $difficulty . ') (test id:' . $testId . ').' . "\n";
$log = str_replace(array('&times;', '&minus;'), array('*', '-'), $log); // Human-readable log in ASCII.
file_put_contents(self::$config['logfile'], $log);
}
return $success;
}
/**
* Returns some informations about the answer of the user.
*
* @param string $testId See self::generate().
*
* @return array An array with the following keys:
* - 'result' -> the numeric result of the calcul
* - 'resultText' -> a typographically-correct result
* - 'text' -> the text (with words)
* - 'numbersText' -> the text (with typographically-correct numbers)
* - 'difficulty' -> the difficulty
*/
public function getAnswer($testId = 'noSpam1') {
if(!isset($_SESSION[self::$config['sessionNamespace']][$testId])) {
return array();
}
return $_SESSION[self::$config['sessionNamespace']][$testId];
}
}
<?php
/**
* The class is auto-documented, following PHPDoc (or JavaDoc) rules.
*/
require_once('path/to/CalculusAntiSpam.php');
header('Content-Type: text/plain');
// Set defaults.
/**
* The PHPDocumentation says:
*
* Allowed options are:
*
* - difficulty: self::DIFFICULTY_EASY, self::DIFFICULTY_MEDIUM or self::DIFFICULTY_HARD
* (see constructor for explanations on difficulty levels);
*
* - sessionNamespace: the session used will be $_SESSION[sessionNamespace];
*
* - numbers: an array with the first item empty, and the following items filled with the
* numbers from one to twenty one.
* Ex: array('', 'one', 'two', ..., 'twenty one').
* Defaults: numbers in french.
*
* - logfile: if you want to be logged the fails, specify here a file. If not, sets to NULL.
* The file must be writable. If it is not, no error will be thrown, and obviously, the log
* will not be saved.
* The log includes the timestamp, the IP, the calculation, the answer, the difficulty level
* and the test ID (see self::generate() for this one).
* » Tip: you can use fail2ban with this log.
*/
CalculusAntiSpam::setDefaults(array(
'difficulty' => CalculusAntiSpam::DIFFICULTY_EASY,
'logfile' => 'path/to/antispam.log'
)
);
#
# In the form
# ************************
$nospam = new CalculusAntiSpam();
// Generates a new calculation.
// This method returns a string like “One + five” or “Eight &times; nine”, according to the configuration for the language used.
echo $this->generate();
// If you want more than one calculation
echo $this->generate('anUniqueKey');
echo $this->generate('anOtherKey');
// If you want to change the configuration (ex between two calculations)
$nospam->setConfig(array('difficulty' => CalculusAntiSpam::DIFFICULTY_HARD));
#
# After the form
# ************************
// If you want to check the result.
if($noSpam->check($userAnswer)) {
// Your code...
}
// With more than one calculation
if($noSpam->check($userAnswer, 'anUniqueKey')) {
// Your code...
}
// If you want to get some informations about the question and the answer of the user (see doc in file)
var_dump( $nospam->getAnswer() );
var_dump( $nospam->getAnswer('anOtherKey') );
/**
* The PHPDocumentation says:
*
* @return array An array with the following keys:
* - 'result' -> the numeric result of the calcul
* - 'resultText' -> a typographically-correct result
* - 'text' -> the text (with words)
* - 'numbersText' -> the text (with typographically-correct numbers)
* - 'difficulty' -> the difficulty
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment