Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.33% covered (success)
93.33%
112 / 120
81.82% covered (success)
81.82%
18 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Captcha
93.33% covered (success)
93.33%
112 / 120
81.82% covered (success)
81.82%
18 / 22
50.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
8.04
 setUrl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setExpire
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setAnswer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setLength
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setUppercase
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setReload
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setConfig
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
10.01
 getUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExpire
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAnswer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isUppercase
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReload
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getImage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getImageHtml
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 getToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createNewToken
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 createImage
86.84% covered (success)
86.84%
33 / 38
0.00% covered (danger)
0.00%
0 / 1
6.08
 random
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 __toString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Pop PHP Framework (http://www.popphp.org/)
4 *
5 * @link       https://github.com/popphp/popphp-framework
6 * @author     Nick Sagona, III <dev@nolainteractive.com>
7 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
8 * @license    http://www.popphp.org/license     New BSD License
9 */
10
11/**
12 * @namespace
13 */
14namespace Pop\Image;
15
16use Pop\Color\Color;
17
18/**
19 * Image captcha class
20 *
21 * @category   Pop
22 * @package    Pop\Image
23 * @author     Nick Sagona, III <dev@nolainteractive.com>
24 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
25 * @license    http://www.popphp.org/license     New BSD License
26 * @version    4.0.0
27 */
28class Captcha
29{
30
31    /**
32     * CAPTCHA URL
33     * @var ?string
34     */
35    protected ?string $url = null;
36
37    /**
38     * CAPTCHA answer length
39     * @var ?string
40     */
41    protected ?string $answer = null;
42
43    /**
44     * CAPTCHA length
45     * @var int
46     */
47    protected int $length = 4;
48
49    /**
50     * CAPTCHA uppercase flag
51     * @var bool
52     */
53    protected bool $uppercase = true;
54
55    /**
56     * CAPTCHA expiration
57     * @var int
58     */
59    protected int $expire = 300;
60
61    /**
62     * CAPTCHA reload text
63     * @var string
64     */
65    protected string $reload = 'Reload';
66
67    /**
68     * CAPTCHA image
69     * @var ?Adapter\AbstractAdapter
70     */
71    protected ?Adapter\AbstractAdapter $image = null;
72
73    /**
74     * Current token data
75     * @var array
76     */
77    protected array $token = [];
78
79    /**
80     * CAPTCHA image config
81     * @var array
82     */
83    protected array $config = [
84        'adapter'     => 'Gd',
85        'width'       => 71,
86        'height'      => 26,
87        'lineSpacing' => 5,
88        'lineColor'   => [175, 175, 175],
89        'textColor'   => [0, 0, 0],
90        'font'        => null,
91        'size'        => 0,
92        'rotate'      => 0
93    ];
94
95    /**
96     * Constructor
97     *
98     * Instantiate the captcha image object
99     *
100     * @param  string $url
101     * @param  int    $expire
102     * @param  ?array $config
103     */
104    public function __construct(string $url, int $expire = 300, ?array $config = null)
105    {
106        $this->setUrl($url);
107        $this->setExpire($expire);
108
109        if ($config !== null) {
110            $this->setConfig($config);
111        }
112
113        // Start a session.
114        if (session_id() == '') {
115            session_start();
116        }
117
118        // If token does not exist, create one
119        if (!isset($_SESSION['pop_captcha']) || (isset($_GET['captcha']) && ((int)$_GET['captcha'] == 1))) {
120            $this->createNewToken();
121        // Else, retrieve existing token
122        } else {
123            $this->token = unserialize($_SESSION['pop_captcha']);
124
125            // Check to see if the token has expired
126            if ($this->token['expire'] > 0) {
127                if (($this->token['expire'] + $this->token['start']) < time()) {
128                    $this->createNewToken();
129                }
130            }
131        }
132    }
133
134    /**
135     * Set CAPTCHA URL
136     *
137     * @param  string $url
138     * @return Captcha
139     */
140    public function setUrl(string $url): Captcha
141    {
142        $this->url = str_replace(['?captcha=1', '&captcha=1'], ['', ''], $url);
143        return $this;
144    }
145
146    /**
147     * Set CAPTCHA expiration
148     *
149     * @param  int $expire
150     * @return Captcha
151     */
152    public function setExpire(int $expire): Captcha
153    {
154        $this->expire = $expire;
155        return $this;
156    }
157
158    /**
159     * Set CAPTCHA answer
160     *
161     * @param  string $answer
162     * @return Captcha
163     */
164    public function setAnswer(string $answer): Captcha
165    {
166        $this->answer = $answer;
167        return $this;
168    }
169
170    /**
171     * Set CAPTCHA answer length
172     *
173     * @param  int $length
174     * @return Captcha
175     */
176    public function setLength(int $length): Captcha
177    {
178        $this->length = $length;
179        return $this;
180    }
181
182    /**
183     * Set CAPTCHA answer case
184     *
185     * @param  bool $uppercase
186     * @return Captcha
187     */
188    public function setUppercase(bool $uppercase): Captcha
189    {
190        $this->uppercase = $uppercase;
191        return $this;
192    }
193
194    /**
195     * Set CAPTCHA reload text
196     *
197     * @param  string $reload
198     * @return Captcha
199     */
200    public function setReload(string $reload): Captcha
201    {
202        $this->reload = $reload;
203        return $this;
204    }
205
206    /**
207     * Set CAPTCHA image config
208     *
209     * @param  array $config
210     * @return Captcha
211     */
212    public function setConfig(array $config): Captcha
213    {
214        if (isset($config['adapter'])) {
215            $this->config['adapter'] = $config['adapter'];
216        }
217        if (isset($config['width'])) {
218            $this->config['width'] = $config['width'];
219        }
220        if (isset($config['height'])) {
221            $this->config['height'] = $config['height'];
222        }
223        if (isset($config['lineSpacing'])) {
224            $this->config['lineSpacing'] = $config['lineSpacing'];
225        }
226        if (isset($config['lineColor'])) {
227            $this->config['lineColor'] = $config['lineColor'];
228        }
229        if (isset($config['textColor'])) {
230            $this->config['textColor'] = $config['textColor'];
231        }
232        if (isset($config['font'])) {
233            $this->config['font'] = $config['font'];
234        }
235        if (isset($config['size'])) {
236            $this->config['size'] = $config['size'];
237        }
238        if (isset($config['rotate'])) {
239            $this->config['rotate'] = $config['rotate'];
240        }
241
242        return $this;
243    }
244
245    /**
246     * Get CAPTCHA URL
247     *
248     * @return string|null
249     */
250    public function getUrl(): string|null
251    {
252        return $this->url;
253    }
254
255    /**
256     * Get CAPTCHA expiration
257     *
258     * @return int
259     */
260    public function getExpire(): int
261    {
262        return $this->expire;
263    }
264
265    /**
266     * Get CAPTCHA answer
267     *
268     * @return string|null
269     */
270    public function getAnswer(): string|null
271    {
272        return $this->answer;
273    }
274
275    /**
276     * Get CAPTCHA answer length
277     *
278     * @return int
279     */
280    public function getLength(): int
281    {
282        return $this->length;
283    }
284
285    /**
286     * Get CAPTCHA answer case
287     *
288     * @return bool
289     */
290    public function isUppercase(): bool
291    {
292        return $this->uppercase;
293    }
294
295    /**
296     * Get CAPTCHA reload text
297     *
298     * @return string
299     */
300    public function getReload(): string
301    {
302        return $this->reload;
303    }
304
305    /**
306     * Get CAPTCHA image config
307     *
308     * @return array
309     */
310    public function getConfig(): array
311    {
312        return $this->config;
313    }
314
315    /**
316     * Get CAPTCHA image
317     *
318     * @return Adapter\AbstractAdapter|null
319     */
320    public function getImage(): Adapter\AbstractAdapter|null
321    {
322        if ($this->image === null) {
323            $this->createImage();
324        }
325        return $this->image;
326    }
327
328    /**
329     * Get CAPTCHA image HTML
330     *
331     * @return string
332     */
333    public function getImageHtml(): string
334    {
335        if ($this->image === null) {
336            $this->createImage();
337        }
338        return (isset($this->token['captcha'])) ? $this->token['captcha'] : '';
339    }
340
341    /**
342     * Get CAPTCHA token
343     *
344     * @return array
345     */
346    public function getToken(): array
347    {
348        return $this->token;
349    }
350
351    /**
352     * Create CAPTCHA token
353     *
354     * @return Captcha
355     */
356    public function createNewToken(): Captcha
357    {
358        $captcha = '<img id="pop-captcha-image" class="pop-captcha-image" src="' . $this->url . '" />' .
359            '<a class="pop-captcha-reload" href="#" onclick="document.getElementById(\'pop-captcha-image\').src = \'' .
360            $this->url . '?captcha=1\'; return false;">' . $this->reload . '</a>';
361
362        $this->token = [
363            'captcha' => $captcha,
364            'answer'  => ($this->answer === null) ? $this->random($this->length, true) : $this->answer,
365            'expire'  => (int)$this->expire,
366            'start'   => time()
367        ];
368
369        $_SESSION['pop_captcha'] = serialize($this->token);
370
371        return $this;
372    }
373
374    /**
375     * Create CAPTCHA image
376     *
377     * @return Captcha
378     */
379    public function createImage(): Captcha
380    {
381        if ($this->config['adapter'] == 'Imagick') {
382            $adapterClass = 'Pop\Image\\' . $this->config['adapter'];
383            $borderSize   = 1.0;
384            $fontSize     = 14;
385            $xyAdjust     = 1;
386        } else {
387            $adapterClass = 'Pop\Image\Gd';
388            $borderSize   = 0.5;
389            $fontSize     = 5;
390            $xyAdjust     = 0;
391        }
392
393        $this->image = $adapterClass::create($this->config['width'], $this->config['height'], 'captcha.gif');
394        $this->image->effect()->fill(new Color\Rgb(255, 255, 255));
395        $this->image->draw()->setStrokeColor(
396            new Color\Rgb($this->config['lineColor'][0], $this->config['lineColor'][1], $this->config['lineColor'][2])
397        );
398
399        // Draw background grid
400        for ($y = $this->config['lineSpacing']; $y <= $this->config['height']; $y += $this->config['lineSpacing']) {
401            $this->image->draw()->line(0, $y -  $xyAdjust, $this->config['width'], $y -  $xyAdjust);
402        }
403
404        for ($x = $this->config['lineSpacing']; $x <= $this->config['width']; $x += $this->config['lineSpacing']) {
405            $this->image->draw()->line($x -  $xyAdjust, 0, $x -  $xyAdjust, $this->config['height']);
406        }
407
408        $this->image->effect()->border(
409            new Color\Rgb($this->config['textColor'][0], $this->config['textColor'][1], $this->config['textColor'][2]), $borderSize
410        );
411        $this->image->type()->setFillColor(
412            new Color\Rgb($this->config['textColor'][0], $this->config['textColor'][1], $this->config['textColor'][2])
413        );
414
415        if ($this->config['font'] === null) {
416            $this->image->type()->size($fontSize);
417            $textX = round(($this->config['width'] - ($this->length * 10)) / 2);
418            $textY = ($adapterClass != 'Pop\Image\Gd') ?
419                $this->config['height'] - (round(($this->config['height'] - $fontSize) / 2)) :
420                round(($this->config['height'] - 16) / 2);
421        } else {
422            $this->image->type()->font($this->config['font'])
423                 ->size($this->config['size']);
424            $textX = round(($this->config['width'] - ($this->length * ($this->config['size'] / 1.5))) / 2);
425            $textY = round($this->config['height'] -
426                (($this->config['height'] - $this->config['size']) / 2) + ((int)$this->config['rotate'] / 2));
427        }
428
429        $this->image->type()->xy($textX, $textY)
430             ->text($this->token['answer']);
431
432        return $this;
433    }
434
435    /**
436     * Create random alphanumeric string
437     *
438     * @param  int  $length
439     * @param  bool $case
440     * @return string
441     */
442    public function random(int $length = 8, bool $case = false)
443    {
444        $chars = [
445            0 => (($case) ? str_split('ABCDEFGHJKMNPQRSTUVWXYZ') : str_split('abcdefghjkmnpqrstuvwxyz')),
446            1 => str_split('23456789')
447        ];
448        $indices = [0, 1];
449        $str     = '';
450
451        for ($i = 0; $i < $length; $i++) {
452            $index    = $indices[rand(0, (count($indices) - 1))];
453            $subIndex = rand(0, (count($chars[$index]) - 1));
454            $str     .= $chars[$index][$subIndex];
455        }
456
457        return $str;
458    }
459
460    /**
461     * Print out the image
462     *
463     * @return string
464     */
465    public function __toString(): string
466    {
467        if ($this->image === null) {
468            $this->createImage();
469        }
470        return (string)$this->getImage();
471    }
472
473}