Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.19% covered (success)
93.19%
178 / 191
68.75% covered (warning)
68.75%
11 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
BuiltinCaptcha
93.19% covered (success)
93.19%
178 / 191
68.75% covered (warning)
68.75%
11 / 16
42.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getFont
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isUserIsLoggedIn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setUserIsLoggedIn
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 renderCaptchaImage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getCaptchaImage
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 createBackground
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 drawLines
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
7
 generateCaptchaCode
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 garbageCollector
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 saveCaptcha
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
3.00
 drawText
74.19% covered (warning)
74.19%
23 / 31
0.00% covered (danger)
0.00%
0 / 1
6.62
 checkCaptchaCode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 validateCaptchaCode
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 removeCaptcha
87.50% covered (success)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 escapeQueryValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The phpMyFAQ Captcha class.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thomas Zeithaml <seo@annatom.de>
12 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
13 * @author    Matteo Scaramuccia <matteo@scaramuccia.com>
14 * @copyright 2006-2026 phpMyFAQ Team
15 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
16 * @link      https://www.phpmyfaq.de
17 * @since     2006-02-04
18 */
19
20declare(strict_types=1);
21
22namespace phpMyFAQ\Captcha;
23
24use Exception;
25use GdImage;
26use phpMyFAQ\Configuration;
27use phpMyFAQ\Database;
28use phpMyFAQ\Strings;
29use Symfony\Component\HttpFoundation\Request;
30
31/**
32 * Class Captcha
33 *
34 * @package phpMyFAQ
35 */
36class BuiltinCaptcha implements CaptchaInterface
37{
38    public int $captchaLength = 6;
39
40    private bool $userIsLoggedIn = false;
41
42    private readonly string $font;
43
44    private string $code = '';
45
46    /** @var string[] */
47    private array $letters = [
48        '1',
49        '2',
50        '3',
51        '4',
52        '5',
53        '6',
54        '7',
55        '8',
56        '9',
57        'A',
58        'B',
59        'C',
60        'D',
61        'E',
62        'F',
63        'G',
64        'H',
65        'I',
66        'J',
67        'K',
68        'L',
69        'M',
70        'N',
71        'O',
72        'P',
73        'Q',
74        'R',
75        'S',
76        'T',
77        'U',
78        'V',
79        'W',
80        'X',
81        'Y',
82        'Z',
83    ];
84
85    private int $width = 200;
86
87    private int $height = 50;
88
89    private int $quality = 80;
90
91    /** @var int[] */
92    private array $backgroundColor;
93
94    private GdImage $gdImage;
95
96    private readonly string $userAgent;
97
98    private readonly int $timestamp;
99
100    private readonly string $ip;
101
102    /**
103     * Constructor.
104     */
105    public function __construct(
106        private readonly Configuration $configuration,
107    ) {
108        $request = Request::createFromGlobals();
109        $this->userAgent = (string) $request->headers->get('user-agent');
110        $this->ip = (string) $request->getClientIp();
111        $this->font = $this->getFont();
112        $this->timestamp = (int) $request->server->get('REQUEST_TIME');
113    }
114
115    /**
116     * Get Fonts.
117     */
118    private function getFont(): string
119    {
120        return (string) PMF_ROOT_DIR . '/assets/fonts/captcha.ttf';
121    }
122
123    public function isUserIsLoggedIn(): bool
124    {
125        return $this->userIsLoggedIn;
126    }
127
128    public function setUserIsLoggedIn(bool $userIsLoggedIn): BuiltinCaptcha
129    {
130        $this->userIsLoggedIn = $userIsLoggedIn;
131        return $this;
132    }
133
134    /**
135     * Gives the HTML output code for the Captcha.
136     */
137    public function renderCaptchaImage(): string
138    {
139        return sprintf(
140            '<img id="captchaImage" class="rounded border" src="./api/captcha" height="%d" width="%d" alt="%s">',
141            $this->height,
142            $this->width,
143            'Chuck Norris has counted to infinity. Twice.',
144        );
145    }
146
147    /**
148     * Returns the Captcha.
149     *
150     * @throws Exception
151     */
152    public function getCaptchaImage(): string
153    {
154        $this->createBackground();
155        $this->drawLines();
156        $this->generateCaptchaCode($this->captchaLength);
157        $this->drawText();
158
159        ob_start();
160        imagejpeg(image: $this->gdImage, file: null, quality: $this->quality);
161
162        $image = ob_get_clean();
163
164        return $image === false ? '' : $image;
165    }
166
167    /**
168     * Create the background.
169     *
170     * @throws Exception
171     */
172    private function createBackground(): void
173    {
174        $this->gdImage = imagecreate($this->width, $this->height);
175        $this->backgroundColor['r'] = random_int(min: 210, max: 255);
176        $this->backgroundColor['g'] = random_int(min: 220, max: 255);
177        $this->backgroundColor['b'] = random_int(min: 210, max: 255);
178
179        $colorAllocate = imagecolorallocate(
180            $this->gdImage,
181            $this->backgroundColor['r'],
182            $this->backgroundColor['g'],
183            $this->backgroundColor['b'],
184        );
185
186        imagefilledrectangle(
187            image: $this->gdImage,
188            x1: 0,
189            y1: 0,
190            x2: $this->width,
191            y2: $this->height,
192            color: $colorAllocate === false ? 0 : $colorAllocate,
193        );
194    }
195
196    /**
197     * Draw random lines.
198     *
199     * @throws Exception
200     */
201    private function drawLines(): void
202    {
203        $color1 = random_int(min: 150, max: 185);
204        $color2 = random_int(min: 185, max: 225);
205        $nextLine = 4;
206        $w1 = 0;
207        $w2 = 0;
208
209        for ($x = 0; $x < $this->width; $x += $nextLine) {
210            if ($x < $this->width) {
211                imageline(
212                    image: $this->gdImage,
213                    x1: $x + $w1,
214                    y1: 0,
215                    x2: $x + $w2,
216                    y2: $this->height - 1,
217                    color: random_int(min: $color1, max: $color2),
218                );
219            }
220
221            if ($x < $this->height) {
222                imageline(
223                    image: $this->gdImage,
224                    x1: 0,
225                    y1: $x - $w2,
226                    x2: $this->width - 1,
227                    y2: $x - $w1,
228                    color: random_int(min: $color1, max: $color2),
229                );
230            }
231
232            if (function_exists('imagettftext')) {
233                $nextLine += random_int(min: -5, max: 7);
234                if ($nextLine < 1) {
235                    $nextLine = 2;
236                }
237            }
238
239            if (!function_exists('imagettftext')) {
240                $nextLine += random_int(min: 1, max: 7);
241            }
242
243            $w1 += random_int(min: -4, max: 4);
244            $w2 += random_int(min: -4, max: 4);
245        }
246    }
247
248    /**
249     * Generate a Captcha Code.
250     *
251     * Start garbage collector for removing old (==unresolved) captcha codes
252     * Note that we would like to avoid performing any garbaging of old records
253     * because these data could be used as a database for collecting ip addresses,
254     * eventually organizing them in subnetwork addresses, in order to use
255     * them as an input for phpMyFAQ IP banning.
256     *
257     * This is because we always perform these three checks on the public forms
258     * in which captcha code feature is attached:
259     *   1. Check against IP/Network address
260     *   2. Check against banned words
261     *   3. Check against the captcha code
262     * so you could ban those "users" at the address level (1.).
263     * If you want to look over your current data you could use this SQL query below:
264     *   SELECT DISTINCT ip, useragent, COUNT(ip) AS times
265     *   FROM faqcaptcha
266     *   GROUP BY ip
267     *   ORDER BY times DESC
268     * to find out *bots and human attempts
269     *
270     * @param int $capLength Length of captcha code
271     * @throws Exception
272     */
273    private function generateCaptchaCode(int $capLength): string
274    {
275        $this->garbageCollector();
276
277        // Create the captcha code
278        for ($i = 1; $i <= $capLength; ++$i) {
279            $this->code .= $this->letters[random_int(min: 0, max: 34)];
280        }
281
282        if (!$this->saveCaptcha()) {
283            return $this->generateCaptchaCode($capLength);
284        }
285
286        return $this->code;
287    }
288
289    /**
290     * Delete old captcha records.
291     * During normal use the <b>faqcaptcha</b> table would be empty, on average:
292     * each record is created when a captcha image is shown to the user
293     * and deleted upon a successful matching, so, on average, a record
294     * in this table is probably related to a spam attack.
295     *                  to be deleted (default: 1 week)
296     */
297    private function garbageCollector(): void
298    {
299        $db = $this->configuration->getDb();
300        $userAgent = $this->escapeQueryValue($this->userAgent);
301        $language = $this->escapeQueryValue($this->configuration->getLanguage()->getLanguage());
302        $ip = $this->escapeQueryValue($this->ip);
303
304        $delete = sprintf(
305            '
306            DELETE FROM 
307                %sfaqcaptcha 
308            WHERE 
309                captcha_time < %d',
310            Database::getTablePrefix(),
311            (int) Request::createFromGlobals()->server->get('REQUEST_TIME') - 604_800,
312        );
313
314        $db->query($delete);
315
316        $delete = sprintf(
317            "
318            DELETE FROM
319                %sfaqcaptcha
320            WHERE
321                useragent = '%s' AND language = '%s' AND ip = '%s'",
322            Database::getTablePrefix(),
323            $userAgent,
324            $language,
325            $ip,
326        );
327
328        $db->query($delete);
329    }
330
331    /**
332     * Save the Captcha.
333     */
334    private function saveCaptcha(): bool
335    {
336        $db = $this->configuration->getDb();
337        $code = $this->escapeQueryValue($this->code);
338        $userAgent = $this->escapeQueryValue($this->userAgent);
339        $language = $this->escapeQueryValue($this->configuration->getLanguage()->getLanguage());
340        $ip = $this->escapeQueryValue($this->ip);
341
342        $select = sprintf("
343           SELECT 
344               id 
345           FROM 
346               %sfaqcaptcha 
347           WHERE 
348                id = '%s'", Database::getTablePrefix(), $code);
349
350        $result = $db->query($select);
351
352        if ($result) {
353            $num = $db->numRows($result);
354            if ($num > 0) {
355                return false;
356            }
357
358            $insert = sprintf(
359                "
360                    INSERT INTO 
361                        %sfaqcaptcha 
362                    (id, useragent, language, ip, captcha_time) 
363                        VALUES 
364                    ('%s', '%s', '%s', '%s', %d)",
365                Database::getTablePrefix(),
366                $code,
367                $userAgent,
368                $language,
369                $ip,
370                $this->timestamp,
371            );
372            $db->query($insert);
373            return true;
374        }
375
376        return false;
377    }
378
379    /**
380     * Draw the Text.
381     *
382     * @throws Exception
383     */
384    private function drawText(): void
385    {
386        $codeLength = Strings::strlen($this->code);
387        $w1 = 25;
388        $w2 = floor($this->width / ($codeLength + 1));
389
390        for ($p = 0; $p < $codeLength; ++$p) {
391            $letter = $this->code[$p];
392            $size = random_int(min: 16, max: $this->height - 3);
393            $rotation = random_int(min: -10, max: 10);
394            $y = random_int($size, $this->height + 5);
395            $x = $w1 + ($w2 * $p);
396            $foreColor = [];
397
398            do {
399                $foreColor['r'] = random_int(min: 30, max: 199);
400            } while ($foreColor['r'] === $this->backgroundColor['r']);
401
402            do {
403                $foreColor['g'] = random_int(min: 30, max: 199);
404            } while ($foreColor['g'] === $this->backgroundColor['g']);
405
406            do {
407                $foreColor['b'] = random_int(min: 30, max: 199);
408            } while ($foreColor['b'] === $this->backgroundColor['b']);
409
410            $colorOne = imagecolorallocate($this->gdImage, $foreColor['r'], $foreColor['g'], $foreColor['b']);
411            $colorOne = $colorOne === false ? 0 : $colorOne;
412
413            // Add the letter
414            if (function_exists('imagettftext')) {
415                imagettftext($this->gdImage, $size, $rotation, (int) $x + 2, $y, $colorOne, $this->font, $letter);
416                imagettftext($this->gdImage, $size, $rotation, (int) $x + 1, $y + 1, $colorOne, $this->font, $letter);
417                imagettftext($this->gdImage, $size, $rotation, (int) $x, $y + 2, $colorOne, $this->font, $letter);
418            }
419
420            if (!function_exists('imagettftext')) {
421                $size = 5;
422                $c3 = imagecolorallocate(image: $this->gdImage, red: 0, green: 0, blue: 255);
423                $c3 = $c3 === false ? 0 : $c3;
424                $x = 20;
425                $y = 12;
426                $s = 30;
427                imagestring($this->gdImage, $size, $x + 1 + ($s * $p), $y + 1, $letter, $c3);
428                imagestring($this->gdImage, $size, $x + ($s * $p), $y, $letter, $colorOne);
429            }
430        }
431    }
432
433    /**
434     * This function checks the provided captcha code
435     * if the captcha code spam protection has been activated from the general PMF configuration.
436     *
437     * @param string|null $code Captcha Code
438     */
439    public function checkCaptchaCode(?string $code = null): bool
440    {
441        if ($this->isUserIsLoggedIn()) {
442            return true;
443        }
444
445        if ($this->configuration->get(item: 'spam.enableCaptchaCode')) {
446            return $this->validateCaptchaCode($code ?? '');
447        }
448
449        return true;
450    }
451
452    /**
453     * Validate the Captcha.
454     *
455     * @param string $captchaCode Captcha code
456     */
457    public function validateCaptchaCode(string $captchaCode): bool
458    {
459        // Check
460        if (Strings::strlen($captchaCode) !== $this->captchaLength) {
461            return false;
462        }
463
464        $captchaCode = Strings::strtoupper($captchaCode);
465        // Help the user: treat "0" (ASCII 48) like "O" (ASCII 79)
466        //                if "0" is not in the realm of captcha code letters
467        if (!in_array('0', $this->letters, strict: true)) {
468            $captchaCode = str_replace(search: '0', replace: 'O', subject: $captchaCode);
469        }
470
471        // Check
472        for ($i = 0; $i < Strings::strlen($captchaCode); ++$i) {
473            if (in_array($captchaCode[$i], $this->letters, strict: true)) {
474                continue;
475            }
476
477            return false;
478        }
479
480        // Search for this Captcha in the db
481        $query = sprintf(
482            "SELECT id FROM %sfaqcaptcha WHERE id = '%s'",
483            Database::getTablePrefix(),
484            $this->configuration->getDb()->escape($captchaCode),
485        );
486
487        $result = $this->configuration->getDb()->query($query);
488        if ($result) {
489            $num = $this->configuration->getDb()->numRows($result);
490            if ($num > 0) {
491                $this->code = $captchaCode;
492                $this->removeCaptcha($captchaCode);
493
494                return true;
495            }
496        }
497
498        return false;
499    }
500
501    /**
502     * Remove the Captcha.
503     *
504     * @param string|null $captchaCode Captcha code
505     */
506    private function removeCaptcha(?string $captchaCode = null): void
507    {
508        if ($captchaCode === null) {
509            $captchaCode = $this->code;
510        }
511
512        $query = sprintf(
513            "DELETE FROM %sfaqcaptcha WHERE id = '%s'",
514            Database::getTablePrefix(),
515            $this->escapeQueryValue($captchaCode),
516        );
517        $this->configuration->getDb()->query($query);
518    }
519
520    private function escapeQueryValue(mixed $value): string
521    {
522        return $this->configuration->getDb()->escape((string) ($value ?? ''));
523    }
524}