1<?php
2// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
3//
4// All Rights Reserved. See copyright.txt for details and a complete list of authors.
5// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
6// $Id$
7
8//this script may only be included - so its better to die if called directly.
9if (strpos($_SERVER['SCRIPT_NAME'], basename(__FILE__)) != false) {
10	header('location: index.php');
11	exit;
12}
13
14/**
15 * A simple class to switch between Zend\Captcha\Image and
16 * Zend\Captcha\ReCaptcha based on admin preference
17 */
18class Captcha
19{
20
21	/**
22	 * The type of the captch ('default' when using Zend\Captcha\Image
23	 * or 'recaptcha' when using Zend\Captcha\ReCaptcha)
24	 *
25	 * @var string
26	 */
27	public $type = '';
28
29	/**
30	 * An instance of Zend\Captcha\Image or Zend\Captcha\ReCaptcha
31	 * depending on the value of $this->type
32	 *
33	 * @var object
34	 */
35	public $captcha = '';
36
37	/**
38	 * Class constructor: decides whether to create an instance of
39	 * Zend\Captcha\Image or Zend\Captcha\ReCaptcha or Captcha_Question
40	 *
41	 * @param string $type recaptcha|questions|default|dumb
42	 */
43	function __construct($type = '')
44	{
45		global $prefs;
46
47		if (empty($type)) {
48			if ($prefs['recaptcha_enabled'] == 'y' && ! empty($prefs['recaptcha_privkey']) && ! empty($prefs['recaptcha_pubkey'])) {
49				if ($prefs['recaptcha_version'] == '2') {
50					$type = 'recaptcha20';
51				} elseif ( $prefs['recaptcha_version'] == '3') {
52					$type = 'recaptcha30';
53				} else {
54					$type = 'recaptcha';
55				}
56			} elseif ($prefs['captcha_questions_active'] == 'y' && ! empty($prefs['captcha_questions'])) {
57				$type = 'questions';
58			} elseif (extension_loaded('gd') && function_exists('imagepng') && function_exists('imageftbbox')) {
59				$type = 'default';
60			} else {
61				$type = 'dumb';
62			}
63		}
64
65		if ($type === 'recaptcha') {
66			$this->captcha = new Zend\Captcha\ReCaptcha(
67				[
68					'private_key' => $prefs['recaptcha_privkey'],
69					'public_key' => $prefs['recaptcha_pubkey'],
70				]
71			);
72			$httpClient = TikiLib::lib('tiki')->get_http_client();
73			$this->captcha->getService()->setHttpClient($httpClient);
74
75			$this->captcha->getService()->setOption('theme', isset($prefs['recaptcha_theme']) ? $prefs['recaptcha_theme'] : 'clean');
76
77			$this->captcha->setOption('ssl', true);
78
79			$this->type = $type;
80
81			$this->recaptchaCustomTranslations();
82		} elseif (in_array($type, ['recaptcha20', 'recaptcha30'])) {
83			$params = [
84				'privkey' => $prefs['recaptcha_privkey'],
85				'pubkey' => $prefs['recaptcha_pubkey'],
86				'theme' => isset($prefs['recaptcha_theme']) ? $prefs['recaptcha_theme'] : 'clean',
87			];
88
89			if ($type === 'recaptcha20') {
90				include_once('lib/captcha/Captcha_ReCaptcha20.php');
91				$this->captcha = new Captcha_ReCaptcha20($params);
92			} else {
93				include_once('lib/captcha/Captcha_ReCaptcha30.php');
94				$this->captcha = new Captcha_ReCaptcha30($params);
95			}
96
97			$httpClient = TikiLib::lib('tiki')->get_http_client();
98			$this->captcha->getService()->setHttpClient($httpClient);
99
100			$this->captcha->setOption('ssl', true);
101
102			$this->type = $type;
103
104			$this->recaptchaCustomTranslations();
105		} elseif ($type === 'default') {
106			$this->captcha = new Zend\Captcha\Image(
107				[
108					'wordLen' => $prefs['captcha_wordLen'],
109					'timeout' => 600,
110					'font' => __DIR__ . '/DejaVuSansMono.ttf',
111					'imgdir' => 'temp/public/',
112					'suffix' => '.captcha.png',
113					'width' => $prefs['captcha_width'],
114					'dotNoiseLevel' => $prefs['captcha_noise'],
115				]
116			);
117			$this->type = 'default';
118		} elseif ($type === 'questions') {
119			$this->type = 'questions';
120
121			$questions = [];
122			$lines = explode("\n", $prefs['captcha_questions']);
123
124			foreach ($lines as $line) {
125				$line = explode(':', $line, 2);
126				if (count($line) === 2) {
127					$questions[] = [trim($line[0]), trim($line[1])];
128				}
129			}
130
131			include_once('lib/captcha/Captcha_Questions.php');
132			$this->captcha = new Captcha_Questions($questions);
133		} else {		// implied $type==='dumb'
134			$this->captcha = new Zend\Captcha\Dumb;
135			$this->captcha->setWordlen($prefs['captcha_wordLen']);
136			$this->captcha->setLabel(tra('Please type this word backwards'));
137			$this->type = 'dumb';
138		}
139
140		$this->setErrorMessages();
141	}
142
143	/**
144	 * Create the default captcha
145	 *
146	 * @return string
147	 */
148	function generate()
149	{
150		$key = '';
151		try {
152			$key = $this->captcha->generate();
153			if ($this->type == 'default' || $this->type == 'questions') {
154				// the following needed to keep session active for ajax checking
155				$session = $this->captcha->getSession();
156				$session->setExpirationHops(2, null, true);
157				$this->captcha->setSession($session);
158				$this->captcha->setKeepSession(false);
159			}
160		} catch (Exception $e) {
161			Feedback::error($e->getMessage());
162		}
163		return $key;
164	}
165
166	/** Return captcha ID
167	 *
168	 * @return string captcha ID
169	 */
170	function getId()
171	{
172		return $this->captcha->getId();
173	}
174
175	/**
176	 * HTML code for the captcha
177	 *
178	 * @return string
179	 */
180	function render()
181	{
182		$access = TikiLib::lib('access');
183		if ($access->is_xml_http_request()) {
184			if (in_array($this->type, ['recaptcha20', 'recaptcha30'])) {
185				return $this->captcha->renderAjax();
186			} elseif ($this->type == 'recaptcha') {
187				$params = json_encode($this->captcha->getService()->getOptions());
188				$id = 1;
189				TikiLib::lib('header')->add_js('
190Recaptcha.create("' . $this->captcha->getPubKey() . '",
191	"captcha' . $id . '",' . $params . '
192  );
193', 100);
194				return '<div id="captcha' . $id . '"></div>';
195			} else {
196				return $this->captcha->render();
197			}
198		} else {
199			if (in_array($this->type, ['recaptcha20', 'recaptcha30'])) {
200				return $this->captcha->render();
201			} elseif ($this->captcha instanceof Zend\Captcha\ReCaptcha) {
202				return $this->captcha->getService()->getHtml();
203			} elseif ($this->captcha instanceof Zend\Captcha\Dumb) {
204				return $this->captcha->getLabel() . ': <b>'
205				. strrev($this->captcha->getWord())
206				. '</b>';
207			}
208			return $this->captcha->render();
209		}
210	}
211
212	/**
213	 * Validate user input for the captcha
214	 *
215	 * @param array $input
216	 * @return bool true or false
217	 */
218	function validate($input = null)
219	{
220		if (is_null($input)) {
221			$input = $_REQUEST;
222		}
223		if (in_array($this->type, ['recaptcha', 'recaptcha20', 'recaptcha30'])) {
224			// Temporary workaround of zend/http client uses arg_separator.output for making POST request body
225			// which fails with Google recaptcha services if used with '&amp;' value
226			// should be fixed in zend/http (pull request submitted)
227			// or remove ini_get('arg_separator.output', '&amp;') we have in tiki code tiki-setup_base.php:31
228			$oldVal = ini_get('arg_separator.output');
229			ini_set('arg_separator.output', '&');
230			$result = $this->captcha->isValid($input);
231			ini_set('arg_separator.output', $oldVal);
232			return $result;
233		} else {
234			return $this->captcha->isValid($input['captcha']);
235		}
236	}
237
238	/**
239	 * Return the full path to the captcha image when using default captcha
240	 *
241	 * @return string full path to default captcha image
242	 */
243	function getPath()
244	{
245		try {
246			return $this->captcha->getImgDir() . $this->captcha->getId() . $this->captcha->getSuffix();
247		} catch (Exception $e) {
248			Feedback::error($e->getMessage());
249		}
250	}
251
252	/**
253	 * Translate Zend\Captcha\Image, Zend\Captcha\Dumb and Zend\Captcha\ReCaptcha
254	 * default error messages
255	 *
256	 * @return void
257	 */
258	function setErrorMessages()
259	{
260		$errors = [
261			'missingValue' => tra('Empty CAPTCHA value'),
262			'badCaptcha' => tra('You have mistyped the anti-bot verification code. Please try again.')
263		];
264
265		if (in_array($this->type, ['recaptcha', 'recaptcha20', 'recaptcha30'])) {
266			$errors['errCaptcha'] = tra('Failed to validate CAPTCHA');
267		} else {
268			$errors['missingID'] = tra('CAPTCHA ID field is missing');
269		}
270
271		$this->captcha->setMessages($errors);
272	}
273
274	/**
275	 * Convert the errors array into a string and return it
276	 *
277	 * @return string error messages
278	 */
279	function getErrors()
280	{
281		return implode('<br />', $this->captcha->getMessages());
282	}
283
284	/**
285	 * Custom translation for ReCaptcha interface
286	 *
287	 * @return void
288	 */
289	function recaptchaCustomTranslations()
290	{
291		$recaptchaService = $this->captcha->getService();
292		$recaptchaService->setOption(
293			'custom_translations',
294			[
295				'visual_challenge' => tra('Get a visual challenge'),
296				'audio_challenge' => tra('Get an audio challenge'),
297				'refresh_btn' => tra('Get a new challenge'),
298				'instructions_visual' => tra('Type the two words'),
299				'instructions_audio' => tra('Type what you hear'),
300				'help_btn' => tra('Help'),
301				'play_again' => tra('Play sound again'),
302				'cant_hear_this' => tra('Download audio as an MP3 file'),
303				'incorrect_try_again' => tra('Incorrect. Try again.')
304			]
305		);
306	}
307}