<?php
/**
 * This file implements the Captcha Questions plugin.
 *
 * The core functionality was provided by Francois PLANQUE.
 *
 * This file is part of the b2evolution/evocms project - {@link http://b2evolution.net/}.
 * See also {@link https://github.com/b2evolution/b2evolution}.
 *
 * @license GNU GPL v2 - {@link http://b2evolution.net/about/gnu-gpl-license}
 *
 * @copyright (c)2012-2016 by Francois Planque - {@link http://fplanque.com/}.
 *
 * {@internal
 * b2evolution is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * b2evolution is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * }}
 *
 * @package plugins
 */
if( !defined('EVO_MAIN_INIT') ) die( 'Please, do not access this page directly.' );


/**
 * The Captcha Questions Plugin.
 *
 * It displays an captcha question through {@link ValidateCaptcha()} and validates
 * it in {@link ValidateCaptcha()}.
 */
class captcha_qstn_plugin extends Plugin
{
	var $version = '7.2.3';
	var $group = 'antispam';
	var $code = 'captcha_qstn';


	/**
	 * Init
	 */
	function PluginInit( & $params )
	{
		$this->name = $this->T_('Captcha questions');
		$this->short_desc = $this->T_('Use questions to tell humans and robots apart.');
	}


	/**
	 * Define the GLOBAL settings of the plugin here. These can then be edited in the backoffice in System > Plugins.
	 *
	 * @param array Associative array of parameters (since v1.9).
	 *    'for_editing': true, if the settings get queried for editing;
	 *                   false, if they get queried for instantiating {@link Plugin::$Settings}.
	 * @return array see {@link Plugin::GetDefaultSettings()}.
	 * The array to be returned should define the names of the settings as keys (max length is 30 chars)
	 * and assign an array with the following keys to them (only 'label' is required):
	 */
	function GetDefaultSettings( & $params )
	{
		global $Settings;

		return array(
				'use_for_anonymous_item' => array(
					'label' => $this->T_('Use for anonymous item forms'),
					'defaultvalue' => 1,
					'note' => $this->T_('Should this plugin be used for anonymous users on item forms?'),
					'type' => 'checkbox',
				),
				'use_for_anonymous_comment' => array(
					'label' => $this->T_('Use for anonymous comment forms'),
					'defaultvalue' => 1,
					'note' => $this->T_('Should this plugin be used for anonymous users on comment forms?'),
					'type' => 'checkbox',
				),
				'use_for_registration' => array(
					'label' => $this->T_('Use for new registration forms'),
					'defaultvalue' => 1,
					'note' => $this->T_('Should this plugin be used on registration forms?'),
					'type' => 'checkbox',
				),
				'use_for_anonymous_message' => array(
					'label' => $this->T_('Use for anonymous messaging forms'),
					'defaultvalue' => 1,
					'note' => $this->T_('Should this plugin be used for anonymous users on messaging forms?'),
					'type' => 'checkbox',
				),
				'questions' => array(
					'label' => $this->T_('Questions'),
					'defaultvalue' => '',
					'note' => $this->T_('Type each question in one line with following format:<br />Question text? answer1|answer2|...|answerN'),
					'cols' => 80,
					'rows' => 10,
					'type' => 'html_textarea',
				),
			);
	}


	/**
	 * We want a table to store our Captcha data (private key, timestamp, ..).
	 *
	 * @return array
	 */
	function GetDbLayout()
	{
		global $DB;

		return array(
				'CREATE TABLE '.$this->get_sql_table('questions').' (
					cptq_ID       INT UNSIGNED NOT NULL AUTO_INCREMENT,
					cptq_question VARCHAR( 255 ) COLLATE utf8mb4_unicode_ci NOT NULL,
					cptq_answers  VARCHAR( 255 ) COLLATE utf8mb4_unicode_ci NOT NULL,
					PRIMARY KEY( cptq_ID )
				) ENGINE = innodb DEFAULT CHARSET = '.$DB->get_connection_charset(),

				// Assign a random question ID for each IP address
				'CREATE TABLE '.$this->get_sql_table('ip_question').' (
					cptip_IP      INT UNSIGNED NOT NULL,
					cptip_cptq_ID INT UNSIGNED NOT NULL,
					KEY( cptip_IP, cptip_cptq_ID )
				) ENGINE = innodb DEFAULT CHARSET = '.$DB->get_connection_charset(),
			);
	}


	/**
	 * Check the available questions in DB table.
	 */
	function BeforeEnable()
	{
		// Convert value of setting "questions" into DB table records
		$this->PluginSettingsUpdateAction();

		if( $error = $this->validate_questions() )
		{
			return $error;
		}

		return true;
	}


	/**
	 * Update the questions
	 */
	function PluginSettingsUpdateAction()
	{
		global $DB, $Messages;

		// Store in this array all questions IDs which are inserted or updated on currect action
		$questions_IDs = array( -1 );
		// Store in this array only questions with correct format
		$questions_formated = array();

		$questions = $this->Settings->get( 'questions' );
		if( empty( $questions ) )
		{ // No questions
			// Delete all questions from DB:
			$DB->query( 'DELETE FROM '.$this->get_sql_table( 'questions' ) );
			return true;
		}

		$questions = explode( "\n", str_replace( "\r\n", "\n", $questions ) );

		$insert_data = array();
		$update_data = array();
		foreach( $questions as $question )
		{
			if( preg_match( '/(.+\?)(.+)/i', $question, $match ) )
			{ // Check a question for correct format
				$question = trim( $match[1] );
				$answers = trim( $match[2] );
				if( !empty( $question ) && !empty( $answers ) )
				{ // Save this question in DB
					$SQL = new SQL( 'Get captcha question' );
					$SQL->SELECT( 'cptq_ID' );
					$SQL->FROM( $this->get_sql_table( 'questions' ) );
					$SQL->WHERE( 'cptq_question = '.$DB->quote( $question ) );

					if( $question_ID = $DB->get_var( $SQL ) )
					{ // This question already exists, we should only update the answers
						$update_data[ $question_ID ] = $answers;
					}
					else
					{ // New question
						$insert_data[] = '( '.$DB->quote( $question ).', '.$DB->quote( $answers ).' )';
					}

					$questions_formated[] = $question.' '.$answers;
				}
			}
			elseif( ! empty( $question ) )
			{ // Incorrect format of question
				$Messages->add( sprintf( T_('Invalid question format in the line %s.'), '"<b>'.$question.'</b>"' ), 'error' );
			}
		}

		if( param_errors_detected() )
		{ // Don't save the changes on errors
			return false;
		}
		else
		{ // Save the changes on success
			if( count( $insert_data ) )
			{ // Insert new questions in DB:
				foreach( $insert_data as $qstn_data )
				{
					$DB->query( 'INSERT INTO '.$this->get_sql_table( 'questions' ).'
						( cptq_question, cptq_answers ) VALUES '.$qstn_data );
					$questions_IDs[] = $DB->insert_id;
				}
			}

			if( count( $update_data ) )
			{ // Update the questions in DB:
				foreach( $update_data as $qstn_ID => $qstn_answers )
				{
					$DB->query( 'UPDATE '.$this->get_sql_table( 'questions' ).'
						  SET cptq_answers = '.$DB->quote( $qstn_answers ).'
						WHERE cptq_ID = '.$qstn_ID );
					$questions_IDs[] = $qstn_ID;
				}
			}

			// Delete the old questions from DB:
			$DB->query( 'DELETE FROM '.$this->get_sql_table( 'questions' ).'
				WHERE cptq_ID NOT IN ( '.implode( ', ', $questions_IDs ).' )' );

			// Resave the questions in order to delere all whitespaces and questions with incorrect format
			$this->Settings->set( 'questions', implode( "\r\n", $questions_formated ) );

			return true;
		}
	}


	/**
	 * Event handler: general event to validate a captcha which payload was added
	 * through {@link RequestCaptcha()}.
	 *
	 * NOTE: if the action is verified/completed in total, you HAVE to cleanup its data
	 *       and is not vulnerable against multiple usage of the same captcha!
	 *
	 * @param array Associative array of parameters
	 * @return boolean true if the catcha could be validated
	 */
	function ValidateCaptcha( & $params )
	{
		if( ! empty( $params['is_preview'] ) || ! isset( $params['form_type'] ) || ! $this->does_apply( $params['form_type'] ) )
		{	// We should not apply captcha to the requested form:
			return true;
		}

		$posted_answer = utf8_strtolower( param( 'captcha_qstn_'.$this->ID.'_answer', 'string', '' ) );

		if( empty( $posted_answer ) )
		{
			$this->debug_log( 'captcha_qstn_'.$this->ID.'_answer' );
			param_error( 'captcha_qstn_'.$this->ID.'_answer', $this->T_('Please enter the captcha answer.') );
			if( $params['form_type'] == 'comment' && ( $comment_Item = & $params['Comment']->get_Item() ) )
			{
				syslog_insert( 'Comment captcha answer is not entered', 'warning', 'item', $comment_Item->ID, 'plugin', $this->ID );
			}
			return false;
		}

		$question = $this->CaptchaQuestion();

		$posted_answer_is_correct = false;

		$answers = explode( '|', utf8_strtolower( $question->cptq_answers ) );
		foreach( $answers as $answer )
		{
			if( $posted_answer == $answer )
			{	// Correct answer is found in DB
				$posted_answer_is_correct = true;
				break;
			}
		}

		if( !$posted_answer_is_correct )
		{
			$this->debug_log( 'Posted ('.$posted_answer.') and saved ('.$question->cptq_answers.') answer do not match!' );
			param_error( 'captcha_qstn_'.$this->ID.'_answer', $this->T_('The entered answer is incorrect.') );
			if( $params['form_type'] == 'comment' && ( $comment_Item = & $params['Comment']->get_Item() ) )
			{
				syslog_insert( 'Comment captcha answer is incorrect', 'warning', 'item', $comment_Item->ID, 'plugin', $this->ID );
			}
			return false;
		}

		// If answer is correct:
		//   We should clean the question ID that was assigned for current session and IP address
		//   It gives to assign new question on the next captcha event
		$this->CaptchaQuestionCleanup();

		return true;
	}


	/**
	 * Get question for current user
	 *
	 * @return object Question row from DB table "questions"
	 */
	function CaptchaQuestion()
	{
		global $DB, $Session;

		$question = NULL;

		$IP = ip2int( $_SERVER['REMOTE_ADDR'] );

		// Get question ID from Session
		$this->question_ID = $Session->get( 'captcha_qstn_'.$this->ID.'_ID' );

		if( empty( $this->question_ID ) )
		{	// Get question from DB by current IP address
			$SQL = new SQL( 'Get captcha question from DB by current IP address' );
			$SQL->SELECT( 'cq.*' );
			$SQL->FROM( $this->get_sql_table( 'ip_question' ) );
			$SQL->FROM_add( 'INNER JOIN '.$this->get_sql_table( 'questions' ).' AS cq ON cptip_cptq_ID = cptq_ID' );
			$SQL->WHERE( 'cptip_IP = '.$DB->quote( $IP ) );

			if( $question = $DB->get_row( $SQL ) )
			{
				$this->question_ID = $question->cptq_ID;
			}
		}

		if( empty( $this->question_ID ) )
		{	// Assign new random question for current IP address
			$question = $this->CaptchaQuestionNew();
		}

		if( empty( $question ) && !empty( $this->question_ID ) )
		{	// Get question data
			$SQL = new SQL( 'Get captcha question data by ID' );
			$SQL->SELECT( '*' );
			$SQL->FROM( $this->get_sql_table( 'questions' ) );
			$SQL->WHERE( 'cptq_ID = '.$DB->quote( $this->question_ID ) );
			$question = $DB->get_row( $SQL );

			if( empty( $question ) )
			{	// Assign random question if previous question doesn't exist in DB
				// This case may happens when admin changed the questions but user has the old question ID in the session or in DB table "ip_question"
				$question = $this->CaptchaQuestionNew();
			}
		}

		return $question;
	}

	/**
	 * Assign new random question for current IP address
	 *
	 * @return object Question row from DB table "questions"
	 */
	function CaptchaQuestionNew()
	{
		global $DB, $Session;

		$IP = ip2int( $_SERVER['REMOTE_ADDR'] );

		// Get new random question from DB
		$SQL = new SQL( 'Get new random captcha question from DB' );
		$SQL->SELECT( '*' );
		$SQL->FROM( $this->get_sql_table( 'questions' ) );
		$SQL->ORDER_BY( 'RAND()' );
		$SQL->LIMIT( 1 );
		$question = $DB->get_row( $SQL );

		// Insert a record for current IP address with assigned question ID
		$DB->query( 'INSERT INTO '.$this->get_sql_table( 'ip_question' ).'
			( cptip_IP, cptip_cptq_ID ) VALUES
			( '.$DB->quote( $IP ).', '.$DB->quote( $question->cptq_ID ).' )' );

		// Save the assigned question ID in the session
		$Session->set( 'captcha_qstn_'.$this->ID.'_ID', $question->cptq_ID );
		$Session->dbsave();

		$this->question_ID = $question->cptq_ID;

		return $question;
	}


	/**
	 * Cleanup used captcha data
	 */
	function CaptchaQuestionCleanup()
	{
		global $DB, $Session;

		$IP = ip2int( $_SERVER['REMOTE_ADDR'] );

		// Remove question ID from session
		$Session->delete( 'captcha_qstn_'.$this->ID.'_ID' );

		// Remove question ID from DB table for current IP address
		$DB->query( 'DELETE FROM '.$this->get_sql_table( 'ip_question' ).'
			WHERE cptip_IP = '.$DB->quote( $IP ) );
	}


	/**
	 * Event handler: Return data to display captcha html code
	 *
	 * @param array Associative array of parameters:
	 *   - 'Form':          Form object
	 *   - 'form_type':     Form type
	 *   - 'form_position': Current form position where this event is called
	 *   - 'captcha_info':  Default captcha info text(can be changed by this plugin)
	 *   - 'captcha_template_question': Default HTML template for question text = '<span class="evo_captcha_question">$captcha_question$</span><br>'
	 *   - 'captcha_template_answer':   Default HTML template for answer field = '<span class="evo_captcha_answer">$captcha_answer$</span><br>'
	 * @return array Associative array of parameters:
	 *   - 'captcha_position': Captcha position where current plugin must be displayed for the requested form type
	 *   - 'captcha_html':     Captcha html code
	 *   - 'captcha_info':     Captcha info text
	 */
	function RequestCaptcha( & $params )
	{
		if( ! isset( $params['form_type'] ) || ! $this->does_apply( $params['form_type'] ) )
		{	// Exit here if the form type is not defined or this captcha plugin should not be applied for the requested form:
			return false;
		}

		switch( $params['form_type'] )
		{	// Set a position where we should display the captcha depending on form type:
			case 'register':
			case 'item':
			case 'comment':
			case 'message':
				$captcha_position = 'before_submit_button';
				break;
			default:
				// The requested form type is not supported by this plugin
				return false;
		}

		$question = $this->CaptchaQuestion();

		if( empty( $question ) )
		{	// No the defined questions
			return false;
		}

		$this->debug_log( 'Question ID is: ('.$this->question_ID.')' );

		// Question text:
		$captcha_html = str_replace( '$captcha_question$', $question->cptq_question, $params['captcha_template_question'] );
		// Answer input field:
		$captcha_field_params = array(
				'type'     => 'text',
				'name'     => 'captcha_qstn_'.$this->ID.'_answer',
				'value'    => param( 'captcha_qstn_'.$this->ID.'_answer', 'string', '' ),
				'size'     => 10,
				'required' => true,
				'class'    => 'form_text_input form-control',
			);
		if( ! empty( $params['use_placeholders'] ) )
		{
			$captcha_field_params['placeholder'] = T_('Please answer the question above');
		}
		$Form = & $params['Form'];
		$captcha_html .= str_replace( '$captcha_answer$', $Form->get_input_element( $captcha_field_params ), $params['captcha_template_answer'] );

		return array(
				'captcha_position' => $captcha_position,
				'captcha_html'     => $captcha_html,
				'captcha_info'     => ( empty( $params['use_placeholders'] ) ? $this->T_('Please answer the question above').'.<br>' : '' ).$params['captcha_info'],
			);
	}


	/* PRIVATE methods */

	/**
	 * Checks if we should captcha the current request, according to the settings made.
	 *
	 * @param string Form type ( comment|register|message )
	 * @return boolean
	 */
	function does_apply( $form_type )
	{
		switch( $form_type )
		{
			case 'item':
				if( !is_logged_in() )
				{
					return $this->Settings->get( 'use_for_anonymous_item' );
				}
				break;

			case 'comment':
				if( !is_logged_in() )
				{
					return $this->Settings->get( 'use_for_anonymous_comment' );
				}
				break;

			case 'register':
				return $this->Settings->get( 'use_for_registration' );

			case 'message':
				if( !is_logged_in() )
				{
					return $this->Settings->get( 'use_for_anonymous_message' );
				}
				break;
		}

		return false;
	}


	/**
	 * Check if questions exist in DB.
	 *
	 * @access private
	 */
	function validate_questions()
	{
		global $DB;

		$SQL = new SQL( 'Check if captcha questions exist in DB' );
		$SQL->SELECT( 'cptq_ID' );
		$SQL->FROM( $this->get_sql_table( 'questions' ) );

		if( ! $DB->get_var( $SQL ) )
		{
			return $this->T_( 'Not Enabled: You should create at least one question!' );
		}
	}

}

?>