diff --git a/.gitattributes b/.gitattributes index 90e9754..37df89d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,7 @@ /.gitattributes export-ignore /.github/ export-ignore /.gitignore export-ignore +/demo/ export-ignore /phpstan.neon.dist export-ignore /phpunit.xml.dist export-ignore /tests/ export-ignore -/demo/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d52240d..d2cf7de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: true matrix: - php: ['5.3.3', '5.3', '5.4', '5.5', '5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4', '8.5'] name: PHP ${{ matrix.php }} @@ -31,15 +31,13 @@ jobs: - name: Execute PHPCS run: | - composer require "squizlabs/php_codesniffer=3.13.2" ./vendor/bin/phpcs --standard=PSR12 src/ tests/ - if: ${{ matrix.php == '8.4' }} + if: ${{ matrix.php == '8.5' }} - name: Execute PHPStan run: | - composer require phpstan/phpstan:2.1.17 phpstan/phpstan-phpunit:2.0.6 ./vendor/bin/phpstan analyse - if: ${{ matrix.php == '8.4' }} + if: ${{ matrix.php == '8.5' }} - name: Execute tests run: ./vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 4383716..f40d3d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ -.idea/ -demo/*.jpg -demo/*.pgm -demo/temp/ -vendor/ +/.idea/ +/.phpunit.result.cache +/composer.lock +/demo/*.jpg +/demo/*.pgm +/demo/temp/ +/vendor/ diff --git a/README.md b/README.md index baef46e..852fd20 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ You can use these functions : * **inline($quality = 80)**, returns the jpeg code directly in the HTML page * **setBackgroundColor($r, $g, $b)**, sets the background color to force it (this will disable many effects and is not recommended) * **setBackgroundAlpha($alpha)**, sets the background transparency 0 is fully opaque, 127 is fully transparent -* **setBackgroundImages(array($imagepath1, $imagePath2))**, Sets custom background images to be used as captcha background. It is recommended to disable image effects when passing custom images for background (ignore_all_effects). A random image is selected from the list passed, the full paths to the image files must be passed. +* **setBackgroundImages([$imagepath1, $imagePath2])**, Sets custom background images to be used as captcha background. It is recommended to disable image effects when passing custom images for background (ignore_all_effects). A random image is selected from the list passed, the full paths to the image files must be passed. * **setInterpolation($interpolate)**, enable or disable the interpolation (enabled by default), disabling it will be quicker but the images will look uglier * **setIgnoreAllEffects($ignoreAllEffects)**, disable all effects on the captcha image. Recommended to use when passing custom background images for the captcha. * **testPhrase($phrase)**, returns true if the given phrase is good @@ -101,7 +101,7 @@ You can use these functions : * **setImageType($type)**, sets the image output type to "jpeg", "png" or "gif" * **setScatterEffect($bool)**, enable or disable scatter effect (fuzzyness) -If you want to change the number of character, you can call the phrase builder directly using +If you want to change the number of characters, you can call the phrase builder directly using extra parameters: ```php @@ -135,13 +135,13 @@ render a captcha and check it after the submission Symfony Bundle ================ -You can have a look at the following repository to enjoy the Symfony 2 bundle packaging this captcha generator : +You can have a look at the following repository to enjoy the Symfony 2 bundle packaging this captcha generator: https://github.com/Gregwar/CaptchaBundle Yii2 Extension =============== -You can use the following extension for integrating with Yii2 Framework : +You can use the following extension for integrating with Yii2 Framework: https://github.com/juliardi/yii2-captcha License diff --git a/composer.json b/composer.json index a76f5d0..b644f28 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ } ], "require": { - "php": ">=5.3.0", + "php": "^8.2", "ext-gd": "*", "ext-mbstring": "*", "ext-fileinfo": "*", @@ -29,6 +29,9 @@ } }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6.4 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^9.6", + "phpstan/phpstan": "2.1.17", + "squizlabs/php_codesniffer": "4.0.1", + "phpstan/phpstan-phpunit": "2.0.6" } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index cf79492..94d5454 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,9 +1,7 @@ -includes: - - ./vendor/phpstan/phpstan-phpunit/extension.neon - parameters: - level: 5 - paths: - - src/ - - tests/ - treatPhpDocTypesAsCertain: false + level: 8 + reportUnmatchedIgnoredErrors: false + paths: + - src/ + - tests/ + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f17e3b4..f5ea52c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,15 +1,13 @@ - - - - - ./tests/ - - - - - - ./src - - + + + + ./src + + + + + ./tests/ + + diff --git a/src/Gregwar/Captcha/CaptchaBuilder.php b/src/Gregwar/Captcha/CaptchaBuilder.php index 0d2892d..4337645 100644 --- a/src/Gregwar/Captcha/CaptchaBuilder.php +++ b/src/Gregwar/Captcha/CaptchaBuilder.php @@ -1,10 +1,17 @@ >|null $textColor */ + protected ?array $textColor = null; - /** - * @var array - */ - protected $backgroundColor = null; + /** @var array>|null $lineColor */ + protected ?array $lineColor = null; + /** @var array>|null $backgroundColor */ + protected ?array $backgroundColor = null; - /** - * @var int 0 to 127, 127 is completely transparent - */ - protected $bgAlpha = 0; + /** @var int<0, 127> $bgAlpha */ + protected int $bgAlpha = 0; - /** - * @var array - */ - protected $backgroundImages = array(); + /** @var string[] $backgroundImages */ + protected array $backgroundImages = []; - /** - * @var resource|\GdImage - */ - protected $contents = null; + protected ?GdImage $contents = null; - /** - * @var string - */ - protected $phrase = null; + protected ?string $phrase = null; - /** - * @var PhraseBuilderInterface - */ - protected $builder; + protected ?PhraseBuilderInterface $builder = null; - /** - * @var bool - */ - protected $distortion = true; + protected bool $distortion = true; /** * The maximum number of lines to draw in front of * the image. null - use default algorithm */ - protected $maxFrontLines = null; + protected ?int $maxFrontLines = null; /** * The maximum number of lines to draw behind * the image. null - use default algorithm */ - protected $maxBehindLines = null; + protected ?int $maxBehindLines = null; /** * The maximum angle of char */ - protected $maxAngle = 8; + protected int $maxAngle = 8; /** * The maximum offset of char */ - protected $maxOffset = 5; + protected int $maxOffset = 5; /** - * Is the interpolation enabled ? - * - * @var bool + * Is the interpolation enabled? */ - protected $interpolation = true; + protected bool $interpolation = true; /** * Ignore all effects - * - * @var bool */ - protected $ignoreAllEffects = false; + protected bool $ignoreAllEffects = false; - /** - * @var bool - */ - protected $scatterEffect = true; + protected bool $scatterEffect = true; /** * Allowed image types for the background images * - * @var array + * @var string[] */ - protected $allowedBackgroundImageTypes = array('image/png', 'image/jpeg', 'image/gif'); + protected array $allowedBackgroundImageTypes = ['image/png', 'image/jpeg', 'image/gif']; - /** - * @var string - */ - protected $imageType = "jpeg"; + protected string $imageType = "jpeg"; + + public function __construct(?string $phrase = null, ?PhraseBuilderInterface $builder = null) + { + $this->builder = (!$builder) + ? new PhraseBuilder() + : $builder; + $this->phrase = is_string($phrase) ? $phrase : $this->builder->build($phrase); + $this->tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'captcha' . DIRECTORY_SEPARATOR; + } /** * The image contents */ - public function getContents() + public function getContents(): ?GdImage { return $this->contents; } /** * Enable/Disables the interpolation - * - * @param $interpolate bool True to enable, false to disable - * - * @return CaptchaBuilder */ - public function setInterpolation($interpolate = true) + public function setInterpolation(bool $interpolate = true): static { $this->interpolation = $interpolate; return $this; } - /** - * Temporary dir, for OCR check - */ - public $tempDir = 'temp/'; - - /** - * @param string|null $phrase - * @param PhraseBuilderInterface|null $builder - */ - public function __construct($phrase = null, $builder = null) - { - if ($builder !== null && !$builder instanceof PhraseBuilderInterface) { - throw new InvalidArgumentException('Builder ($builder) must implement PhraseBuilderInterface'); - } - - $this->builder = $builder ?: new PhraseBuilder(); - $this->phrase = is_string($phrase) ? $phrase : $this->builder->build($phrase); - } - - public function setImageType($imageType) + public function setImageType(?string $imageType = null): static { $imageType = is_string($imageType) ? strtolower($imageType) : ''; - if (in_array($imageType, array('png', 'jpeg', 'gif'))) { + if (in_array($imageType, ['png', 'jpeg', 'gif'])) { $this->imageType = $imageType; } return $this; } - public function getImageType() + public function getImageType(): string { return $this->imageType; } @@ -183,15 +141,15 @@ public function getImageType() /** * Setting the phrase */ - public function setPhrase($phrase) + public function setPhrase(?string $phrase = null): void { - $this->phrase = (string) $phrase; + $this->phrase = $phrase; } /** * Enables/disable distortion */ - public function setDistortion($distortion) + public function setDistortion(bool|int $distortion): static { $this->distortion = (bool) $distortion; @@ -200,42 +158,36 @@ public function setDistortion($distortion) /** * Enables/disable scatter effect - Only applies to PHP 7.4+ - * - * @param bool $scatterEffect */ - public function setScatterEffect($scatterEffect) + public function setScatterEffect(bool $scatterEffect): static { - if (version_compare(PHP_VERSION, '7.4.0') < 0) { - throw new LogicException('Scatter effect is only available on PHP 7.4'); - } - - $this->scatterEffect = (bool) $scatterEffect; + $this->scatterEffect = $scatterEffect; return $this; } - public function setMaxBehindLines($maxBehindLines) + public function setMaxBehindLines(?int $maxBehindLines): static { $this->maxBehindLines = $maxBehindLines; return $this; } - public function setMaxFrontLines($maxFrontLines) + public function setMaxFrontLines(?int $maxFrontLines): static { $this->maxFrontLines = $maxFrontLines; return $this; } - public function setMaxAngle($maxAngle) + public function setMaxAngle(int $maxAngle): static { $this->maxAngle = $maxAngle; return $this; } - public function setMaxOffset($maxOffset) + public function setMaxOffset(int $maxOffset): static { $this->maxOffset = $maxOffset; @@ -245,7 +197,7 @@ public function setMaxOffset($maxOffset) /** * Gets the captcha phrase */ - public function getPhrase() + public function getPhrase(): ?string { return $this->phrase; } @@ -253,46 +205,57 @@ public function getPhrase() /** * Returns true if the given phrase is good */ - public function testPhrase($phrase) + public function testPhrase(string $phrase): bool { - return ($this->builder->niceize($phrase) == $this->builder->niceize($this->getPhrase())); + return ( + $this->getPhrase() + && $this->builder + && $this->builder->niceize($phrase) == $this->builder->niceize($this->getPhrase())); } /** * Instantiation */ - public static function create($phrase = null) + public static function create(?string $phrase = null): CaptchaBuilder { return new self($phrase); } /** * Sets the text color to use + * @param int<0, 255> $r + * @param int<0, 255> $g + * @param int<0, 255> $b */ - public function setTextColor($r, $g, $b) + public function setTextColor(int $r, int $g, int $b): static { - $this->textColor = array($r, $g, $b); + $this->textColor = [$r, $g, $b]; return $this; } /** * Sets the background color to use + * @param int<0, 255> $r + * @param int<0, 255> $g + * @param int<0, 255> $b */ - public function setBackgroundColor($r, $g, $b) + public function setBackgroundColor(int $r, int $g, int $b): static { - $this->backgroundColor = array($r, $g, $b); + $this->backgroundColor = [$r, $g, $b]; return $this; } /** - * @param int $alpha 0 to 127, 127 is completely transparent + * @param int<0, 127> $alpha 0 to 127, 127 is completely transparent * @return $this + * @throws InvalidArgumentException + * @throws LogicException */ - public function setBackgroundAlpha($alpha) + public function setBackgroundAlpha(int $alpha): static { - if (! is_integer($alpha) || $alpha < 0 || $alpha > 127) { + if ($alpha < 0 || $alpha > 127) { throw new InvalidArgumentException('Argument $alpha must be an integer between 0 and 127.'); } @@ -305,28 +268,28 @@ public function setBackgroundAlpha($alpha) return $this; } - public function getBackgroundAlpha() + public function getBackgroundAlpha(): int { return $this->bgAlpha; } /** * Sets the line color to use + * @param int<0, 255> $r + * @param int<0, 255> $g + * @param int<0, 255> $b */ - public function setLineColor($r, $g, $b) + public function setLineColor(int $r, int $g, int $b): static { - $this->lineColor = array($r, $g, $b); + $this->lineColor = [$r, $g, $b]; return $this; } /** * Sets the ignoreAllEffects value - * - * @param bool $ignoreAllEffects - * @return CaptchaBuilder */ - public function setIgnoreAllEffects($ignoreAllEffects) + public function setIgnoreAllEffects(bool $ignoreAllEffects): static { $this->ignoreAllEffects = $ignoreAllEffects; @@ -335,8 +298,9 @@ public function setIgnoreAllEffects($ignoreAllEffects) /** * Sets the list of background images to use (one image is randomly selected) + * @param string[] $backgroundImages */ - public function setBackgroundImages(array $backgroundImages) + public function setBackgroundImages(array $backgroundImages): static { $this->backgroundImages = $backgroundImages; @@ -346,16 +310,16 @@ public function setBackgroundImages(array $backgroundImages) /** * Draw lines over the image */ - protected function drawLine($image, $width, $height, $tcol = null) + protected function drawLine(GdImage $image, int $width = 150, int $height = 40, ?int $tcol = null): void { - if ($this->lineColor === null) { - $red = $this->rand(100, 255); - $green = $this->rand(100, 255); - $blue = $this->rand(100, 255); - } else { + if ($this->lineColor !== null) { $red = $this->lineColor[0]; $green = $this->lineColor[1]; $blue = $this->lineColor[2]; + } else { + $red = $this->rand(100, 255); + $green = $this->rand(100, 255); + $blue = $this->rand(100, 255); } if ($tcol === null) { @@ -363,24 +327,24 @@ protected function drawLine($image, $width, $height, $tcol = null) } if ($this->rand(0, 1)) { // Horizontal - $Xa = $this->rand(0, $width / 2); + $Xa = $this->rand(0, (int)($width / 2)); $Ya = $this->rand(0, $height); - $Xb = $this->rand($width / 2, $width); + $Xb = $this->rand((int)($width / 2), $width); $Yb = $this->rand(0, $height); } else { // Vertical $Xa = $this->rand(0, $width); - $Ya = $this->rand(0, $height / 2); + $Ya = $this->rand(0, (int)($height / 2)); $Xb = $this->rand(0, $width); - $Yb = $this->rand($height / 2, $height); + $Yb = $this->rand((int)($height / 2), $height); } imagesetthickness($image, $this->rand(1, 3)); - imageline($image, $Xa, $Ya, $Xb, $Yb, $tcol); + imageline($image, $Xa, $Ya, $Xb, $Yb, (int)$tcol); } /** * Apply some post effects */ - protected function postEffect($image, $bg) + protected function postEffect(GdImage $image, ?int $bg = null): void { if (!function_exists('imagefilter')) { return; @@ -395,7 +359,7 @@ protected function postEffect($image, $bg) if (defined('IMG_FILTER_SCATTER')) { if ($this->scatterEffect && $this->rand(0, 3) != 0 && $bg != null) { $scattered = true; - imagefilter($image, IMG_FILTER_SCATTER, 0, 2, array($bg)); + imagefilter($image, IMG_FILTER_SCATTER, 0, 2, [$bg]); } } @@ -421,57 +385,78 @@ protected function postEffect($image, $bg) /** * Writes the phrase on the image */ - protected function writePhrase($image, $phrase, $font, $width, $height) + protected function writePhrase(GdImage $image, ?string $phrase, string $font, int $width, int $height): ?int { - $length = mb_strlen($phrase); - if ($length === 0) { - return \imagecolorallocate($image, 0, 0, 0); + $length = mb_strlen((string)$phrase); + if ($length === 0 || !$phrase) { + return imagecolorallocate($image, 0, 0, 0) ?: null; } // Gets the text size and start position $size = (int) round($width / $length) - $this->rand(0, 3) - 1; - $box = \imagettfbbox($size, 0, $font, $phrase); + $box = imagettfbbox($size, 0, $font, $phrase); + if (!$box) { + return null; + } $textWidth = $box[2] - $box[0]; $textHeight = $box[1] - $box[7]; $x = (int) round(($width - $textWidth) / 2); $y = (int) round(($height - $textHeight) / 2) + $size; if (!$this->textColor) { - $textColor = array($this->rand(0, 150), $this->rand(0, 150), $this->rand(0, 150)); + $textColor = [$this->rand(0, 150), $this->rand(0, 150), $this->rand(0, 150)]; } else { $textColor = $this->textColor; } - $col = \imagecolorallocate($image, $textColor[0], $textColor[1], $textColor[2]); - - // Write the letters one by one, with random angle - for ($i = 0; $i < $length; $i++) { - $symbol = mb_substr($phrase, $i, 1); - $box = \imagettfbbox($size, 0, $font, $symbol); - $w = $box[2] - $box[0]; - $angle = $this->rand(-$this->maxAngle, $this->maxAngle); - $offset = $this->rand(-$this->maxOffset, $this->maxOffset); - \imagettftext($image, $size, $angle, $x, $y + $offset, $col, $font, $symbol); - $x += $w; + $col = imagecolorallocate($image, $textColor[0], $textColor[1], $textColor[2]); + if ($col !== false) { + // Write the letters one by one, with random angle + for ($i = 0; $i < $length; $i++) { + $symbol = mb_substr($phrase, $i, 1); + $box = imagettfbbox($size, 0, $font, $symbol); + if (!$box) { + return null; + } + $w = $box[2] - $box[0]; + $angle = $this->rand(-$this->maxAngle, $this->maxAngle); + $offset = $this->rand(-$this->maxOffset, $this->maxOffset); + imagettftext($image, $size, $angle, $x, $y + $offset, $col, $font, $symbol); + $x += $w; + } } - return $col; + return $col ?: null; } /** * Try to read the code against an OCR + * @throws CaptchaException */ - public function isOCRReadable() + public function isOCRReadable(): bool { - if (!is_dir($this->tempDir)) { - @mkdir($this->tempDir, 0755, true); + if (!is_dir($this->tempDir) && !mkdir($this->tempDir, 0755, true)) { + throw new CaptchaException('Failed to create temporary directory for OCR check: ' . $this->tempDir); } $tempj = $this->tempDir . uniqid('captcha', true) . '.jpg'; $tempp = $this->tempDir . uniqid('captcha', true) . '.pgm'; $this->save($tempj); - shell_exec("convert $tempj $tempp"); - $value = trim(strtolower(shell_exec("ocrad $tempp"))); + + shell_exec("convert " . escapeshellarg($tempj) . " " . escapeshellarg($tempp)); + if (!file_exists($tempp)) { + if (!file_exists($tempj)) { + @unlink($tempj); + } + + throw new CaptchaException('isOCRReadable failed to convert file for testing.'); + } + + $ocradOutput = shell_exec("ocrad " . escapeshellarg($tempp)); + $value = ''; + if ($ocradOutput) { + $value = trim(strtolower($ocradOutput)); + } @unlink($tempj); @unlink($tempp); @@ -481,9 +466,15 @@ public function isOCRReadable() /** * Builds while the code is readable against an OCR - */ - public function buildAgainstOCR($width = 150, $height = 40, $font = null, $fingerprint = null) - { + * @param int[] $fingerprint + * @throws CaptchaException + */ + public function buildAgainstOCR( + int $width = 150, + int $height = 40, + ?string $font = null, + ?array $fingerprint = null + ): void { do { $this->build($width, $height, $font, $fingerprint); } while ($this->isOCRReadable()); @@ -491,14 +482,16 @@ public function buildAgainstOCR($width = 150, $height = 40, $font = null, $finge /** * Generate the image + * @param int[] $fingerprint + * @throws CaptchaException */ - public function build($width = 150, $height = 40, $font = null, $fingerprint = null) + public function build(int $width = 150, int $height = 40, ?string $font = null, ?array $fingerprint = null): static { if (null !== $fingerprint) { $this->fingerprint = $fingerprint; $this->useFingerprint = true; } else { - $this->fingerprint = array(); + $this->fingerprint = []; $this->useFingerprint = false; } @@ -506,9 +499,10 @@ public function build($width = 150, $height = 40, $font = null, $fingerprint = n $font = __DIR__ . '/Font/captcha' . $this->rand(0, 5) . '.ttf'; } - if (empty($this->backgroundImages)) { + $bg = 0; + if (empty($this->backgroundImages) && $width > 0 && $height > 0) { // if background images list is not set, use a color fill as a background - $image = imagecreatetruecolor($width, $height); + $image = imagecreatetruecolor($width, $height); if ($this->backgroundColor == null) { $bg = imagecolorallocatealpha( $image, @@ -521,7 +515,8 @@ public function build($width = 150, $height = 40, $font = null, $fingerprint = n $color = $this->backgroundColor; $bg = imagecolorallocatealpha($image, $color[0], $color[1], $color[2], $this->bgAlpha); } - imagefill($image, 0, 0, $bg); + + imagefill($image, 0, 0, $bg ?: 0); imagesavealpha($image, true); } else { // use a random background image @@ -531,11 +526,13 @@ public function build($width = 150, $height = 40, $font = null, $fingerprint = n $image = $this->createBackgroundImageFromType($randomBackgroundImage, $imageType); } - + if (!$image) { + throw new LogicException('Failed to create background image'); + } // Apply effects if (!$this->ignoreAllEffects) { $square = $width * $height; - $effects = $this->rand($square / 3000, $square / 2000); + $effects = $this->rand((int)($square / 3000), (int)($square / 2000)); // set the maximum number of lines to draw in front of the text if ($this->maxBehindLines != null && $this->maxBehindLines > 0) { @@ -555,7 +552,7 @@ public function build($width = 150, $height = 40, $font = null, $fingerprint = n // Apply effects if (!$this->ignoreAllEffects) { $square = $width * $height; - $effects = $this->rand($square / 3000, $square / 2000); + $effects = $this->rand((int)($square / 3000), (int)($square / 2000)); // set the maximum number of lines to draw in front of the text if ($this->maxFrontLines != null && $this->maxFrontLines > 0) { @@ -570,13 +567,13 @@ public function build($width = 150, $height = 40, $font = null, $fingerprint = n } // Distort the image - if ($this->distortion && !$this->ignoreAllEffects) { - $image = $this->distort($image, $width, $height, isset($bg) ? $bg : 0); + if ($this->distortion && !$this->ignoreAllEffects && $width > 0 && $height > 0) { + $image = $this->distort($image, $width, $height, $bg ?: 0); } // Post effects - if (!$this->ignoreAllEffects) { - $this->postEffect($image, isset($bg) ? $bg : 0); + if (!$this->ignoreAllEffects && $image) { + $this->postEffect($image, $bg ?: 0); } $this->contents = $image; @@ -586,8 +583,10 @@ public function build($width = 150, $height = 40, $font = null, $fingerprint = n /** * Distorts the image + * @param int<1, max> $width + * @param int<1, max> $height */ - public function distort($image, $width, $height, $bg) + public function distort(GdImage $image, int $width, int $height, int $bg = 0): ?GdImage { $contents = imagecreatetruecolor($width, $height); imagefill($contents, 0, 0, $bg); @@ -633,18 +632,22 @@ public function distort($image, $width, $height, $bg) } } - return $contents; + return $contents ?: null; } /** * Saves the Captcha to file + * @throws CaptchaException */ - public function save($filename, $quality = 90) + public function save(?string $filename = null, int $quality = 90): void { $imageType = $this->getImageType(); + if (!$this->contents) { + throw new CaptchaException('No image generated'); + } switch ($imageType) { case "png": - imagepng($this->contents, $filename, $quality / 10); // quality 0-9 + imagepng($this->contents, $filename, (int)($quality / 10)); // quality 0-9 break; case "gif": imagegif($this->contents, $filename); @@ -658,42 +661,45 @@ public function save($filename, $quality = 90) /** * Gets the image GD */ - public function getGd() + public function getGd(): ?GdImage { return $this->contents; } /** * Gets the image contents + * + * @throws CaptchaException */ - public function get($quality = 90) + public function get(int $quality = 90): string { ob_start(); $this->output($quality); - return ob_get_clean(); + return ob_get_clean() ?: ''; } /** * Gets the HTML inline base64 */ - public function inline($quality = 90) + public function inline(int $quality = 90): string { return sprintf('data:image/%s;base64,%s', $this->getImageType(), base64_encode($this->get($quality))); } /** * Outputs the image + * @throws CaptchaException */ - public function output($quality = 90) + public function output(int $quality = 90): void { $this->save(null, $quality); } /** - * @return array + * @return int[] */ - public function getFingerprint() + public function getFingerprint(): array { return $this->fingerprint; } @@ -701,35 +707,26 @@ public function getFingerprint() /** * Returns a random number or the next number in the * fingerprint + * @return int<0, 255> */ - protected function rand($min, $max) + protected function rand(int $min, int $max): int { - if (!is_array($this->fingerprint)) { - $this->fingerprint = array(); - } - if ($this->useFingerprint) { - $value = current($this->fingerprint); + $value = (int)current($this->fingerprint); + $value = max(0, $value); + $value = min(255, $value); next($this->fingerprint); } else { - $value = mt_rand((int)$min, (int)$max); + $value = mt_rand($min, $max); + $value = max(0, $value); + $value = min(255, $value); $this->fingerprint[] = $value; } return $value; } - /** - * @param $x - * @param $y - * @param $nw - * @param $ne - * @param $sw - * @param $se - * - * @return int - */ - protected function interpolate($x, $y, $nw, $ne, $sw, $se) + protected function interpolate(float $x, float $y, int $nw, int $ne, int $sw, int $se): int { list($r0, $g0, $b0) = $this->getRGB($nw); list($r1, $g1, $b1) = $this->getRGB($ne); @@ -754,14 +751,7 @@ protected function interpolate($x, $y, $nw, $ne, $sw, $se) return ($r << 16) | ($g << 8) | $b; } - /** - * @param $image - * @param $x - * @param $y - * - * @return int - */ - protected function getCol($image, $x, $y, $background) + protected function getCol(GdImage $image, float|int $x, float|int $y, int $background): int { $L = imagesx($image); $H = imagesy($image); @@ -769,31 +759,26 @@ protected function getCol($image, $x, $y, $background) return $background; } - return imagecolorat($image, $x, $y); + return imagecolorat($image, (int)$x, (int)$y) ?: 0; } /** - * @param $col - * - * @return array + * @return array{int, int, int} */ - protected function getRGB($col) + protected function getRGB(int $col): array { - return array( - (int) ($col >> 16) & 0xff, - (int) ($col >> 8) & 0xff, - (int) ($col) & 0xff, - ); + return [ + ($col >> 16) & 0xff, + ($col >> 8) & 0xff, + $col & 0xff, + ]; } /** * Validate the background image path. Return the image type if valid - * - * @param string $backgroundImage - * @return string - * @throws Exception + * @throws CaptchaException */ - protected function validateBackgroundImage($backgroundImage) + protected function validateBackgroundImage(string $backgroundImage): ?string { // check if file exists if (!file_exists($backgroundImage)) { @@ -802,48 +787,40 @@ protected function validateBackgroundImage($backgroundImage) ? $backgroundImageExploded[count($backgroundImageExploded) - 1] : $backgroundImage; - throw new Exception('Invalid background image: ' . $imageFileName); + throw new CaptchaException('Invalid background image: ' . $imageFileName); } // check image type - $finfo = finfo_open(FILEINFO_MIME_TYPE); // return mime type ala mimetype extension + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if (!$finfo) { + throw new CaptchaException('Failed finfo_open'); + } + $imageType = finfo_file($finfo, $backgroundImage); finfo_close($finfo); if (!in_array($imageType, $this->allowedBackgroundImageTypes)) { - throw new Exception( + throw new CaptchaException( 'Invalid background image type! Allowed types are: ' . join(', ', $this->allowedBackgroundImageTypes) ); } - return $imageType; + return $imageType ?: null; } /** * Create background image from type - * - * @param string $backgroundImage - * @param string $imageType - * @return resource|\GdImage - * @throws Exception + * @throws CaptchaException */ - protected function createBackgroundImageFromType($backgroundImage, $imageType) + protected function createBackgroundImageFromType(string $backgroundImage, ?string $imageType = null): GdImage { - switch ($imageType) { - case 'image/jpeg': - $image = imagecreatefromjpeg($backgroundImage); - break; - case 'image/png': - $image = imagecreatefrompng($backgroundImage); - break; - case 'image/gif': - $image = imagecreatefromgif($backgroundImage); - break; - - default: - throw new Exception('Not supported file type for background image!'); - } + $image = match ($imageType) { + 'image/jpeg' => imagecreatefromjpeg($backgroundImage), + 'image/png' => imagecreatefrompng($backgroundImage), + 'image/gif' => imagecreatefromgif($backgroundImage), + default => throw new InvalidArgumentException('Not supported file type for background image!'), + }; if ($image === false) { throw new LogicException('Failed to create background image!'); diff --git a/src/Gregwar/Captcha/CaptchaBuilderInterface.php b/src/Gregwar/Captcha/CaptchaBuilderInterface.php index bebd070..a7f4661 100644 --- a/src/Gregwar/Captcha/CaptchaBuilderInterface.php +++ b/src/Gregwar/Captcha/CaptchaBuilderInterface.php @@ -1,5 +1,7 @@ - * @author Jeremy Livingston - */ -class ImageFileHandler -{ - /** - * Name of folder for captcha images - * - * @var string - */ - protected $imageFolder; - - /** - * Absolute path to public web folder - * - * @var string - */ - protected $webPath; - - /** - * Frequency of garbage collection in fractions of 1 - * - * @var int - */ - protected $gcFreq; - - /** - * Maximum age of images in minutes - * - * @var int - */ - protected $expiration; - - /** - * @param $imageFolder - * @param $webPath - * @param $gcFreq - * @param $expiration - */ - public function __construct($imageFolder, $webPath, $gcFreq, $expiration) - { - $this->imageFolder = $imageFolder; - $this->webPath = $webPath; - $this->gcFreq = $gcFreq; - $this->expiration = $expiration; - } - - /** - * Saves the provided image content as a file - * - * @param resource|\GdImage $contents - * - * @return string - */ - public function saveAsFile($contents) - { - $this->createFolderIfMissing(); - - $filename = md5(uniqid()) . '.jpg'; - $filePath = $this->webPath . '/' . $this->imageFolder . '/' . $filename; - imagejpeg($contents, $filePath, 15); - - return '/' . $this->imageFolder . '/' . $filename; - } - - /** - * Randomly runs garbage collection on the image directory - * - * @return bool - */ - public function collectGarbage() - { - if (!mt_rand(1, $this->gcFreq) == 1) { - return false; - } - - $this->createFolderIfMissing(); - - $finder = new Finder(); - $criteria = sprintf('<= now - %s minutes', $this->expiration); - $finder->in($this->webPath . '/' . $this->imageFolder) - ->date($criteria); - - foreach ($finder->files() as $file) { - unlink($file->getPathname()); - } - - return true; - } - - /** - * Creates the folder if it doesn't exist - */ - protected function createFolderIfMissing() - { - if (!file_exists($this->webPath . '/' . $this->imageFolder)) { - mkdir($this->webPath . '/' . $this->imageFolder, 0755); - } - } -} diff --git a/src/Gregwar/Captcha/PhraseBuilder.php b/src/Gregwar/Captcha/PhraseBuilder.php index 5d79b1c..09be1d4 100644 --- a/src/Gregwar/Captcha/PhraseBuilder.php +++ b/src/Gregwar/Captcha/PhraseBuilder.php @@ -1,5 +1,7 @@ length = $length; - $this->charset = $charset; + public function __construct( + public int $length = 5, + public string $charset = 'abcdefghijklmnpqrstuvwxyz123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' + ) { + // } /** - * Generates random phrase of given length with given charset + * Generates a random phrase of given length with given charset */ - public function build($length = null, $charset = null) + public function build(?int $length = null, ?string $charset = null): string { if ($length !== null) { $this->length = $length; @@ -52,7 +43,7 @@ public function build($length = null, $charset = null) /** * "Niceize" a code */ - public function niceize($str) + public function niceize(string $str): string { return self::doNiceize($str); } @@ -60,7 +51,7 @@ public function niceize($str) /** * A static helper to niceize */ - public static function doNiceize($str) + public static function doNiceize(string $str): string { return strtr(strtolower($str), '01', 'ol'); } @@ -68,7 +59,7 @@ public static function doNiceize($str) /** * A static helper to compare */ - public static function comparePhrases($str1, $str2) + public static function comparePhrases(string $str1, string $str2): bool { return self::doNiceize($str1) === self::doNiceize($str2); } diff --git a/src/Gregwar/Captcha/PhraseBuilderInterface.php b/src/Gregwar/Captcha/PhraseBuilderInterface.php index 7333b6c..311b7c5 100644 --- a/src/Gregwar/Captcha/PhraseBuilderInterface.php +++ b/src/Gregwar/Captcha/PhraseBuilderInterface.php @@ -1,5 +1,7 @@ assertInstanceOf('Gregwar\Captcha\CaptchaBuilder', CaptchaBuilder::create()); } - public function testBuild() + public function testBuild(): void { $this->assertInstanceOf('Gregwar\Captcha\CaptchaBuilder', CaptchaBuilder::create()->build()); @@ -35,18 +35,17 @@ public function testBuild() } } - public function testDemo() + public function testDemo(): void { $captcha = new CaptchaBuilder(); $captcha ->build() - ->save('out.jpg') - ; + ->save($filename = __DIR__ . '/../generated/out.jpg'); - $this->assertTrue(file_exists(__DIR__ . '/../out.jpg')); + $this->assertTrue(file_exists($filename)); } - public function testFingerPrint() + public function testFingerPrint(): void { $int = count(CaptchaBuilder::create() ->build() @@ -55,45 +54,44 @@ public function testFingerPrint() $this->assertTrue(is_int($int)); // @phpstan-ignore function.alreadyNarrowedType } - public function testImageType() + public function testImageType(): void { - $types = array('jpeg' => IMAGETYPE_JPEG, 'png' => IMAGETYPE_PNG, 'gif' => IMAGETYPE_GIF); + $types = [ + 'jpeg' => IMAGETYPE_JPEG, + 'png' => IMAGETYPE_PNG, + 'gif' => IMAGETYPE_GIF + ]; foreach ($types as $type => $expected) { $captcha = new CaptchaBuilder(); $captcha->setImageType($type)->build(); // Test save() - $captcha->save('out.' . $type); - $this->assertType(__DIR__ . '/../out.' . $type, $expected); + $captcha->save($filename = __DIR__ . '/../generated/out.' . $type); + $this->assertType($filename, $expected); // Test output() ob_start(); $captcha->output(); - file_put_contents(__DIR__ . '/../out.' . $type, ob_get_clean()); - $this->assertType(__DIR__ . '/../out.' . $type, $expected); + file_put_contents($filename, ob_get_clean()); + $this->assertType($filename, $expected); } } - public function testImageTransparency() + public function testImageTransparency(): void { - foreach (array(0 => false, 127 => true) as $alpha => $expected) { + foreach ([0 => false, 127 => true] as $alpha => $expected) { $captcha = new CaptchaBuilder(); $captcha->setImageType('png') ->setBackgroundColor(0, 0, 0) ->setBackgroundAlpha($alpha) ->build() - ->save($filename = __DIR__ . '/out.png'); + ->save($filename = __DIR__ . '/../generated/out.png'); $this->assertTransparency($filename, $expected); } } - /** - * @param string $filename - * @param bool $expected - * @return void - */ - private function assertTransparency($filename, $expected) + private function assertTransparency(string $filename, bool $expected): void { $image = imagecreatefrompng($filename); if (!$image) { @@ -115,17 +113,11 @@ private function assertTransparency($filename, $expected) } } } - imagedestroy($image); $this->assertSame($expected, $hasTransparency, 'The PNG does not have any transparent pixels.'); } - /** - * @param string $file - * @param int $expected IMAGETYPE_JPEG / IMAGETYPE_PNG / IMAGETYPE_GIF - * @return void - */ - private function assertType($file, $expected) + private function assertType(string $file, int $expected): void { $info = getimagesize($file); if ($info === false) { diff --git a/tests/generated/.gitignore b/tests/generated/.gitignore new file mode 100644 index 0000000..593bcf0 --- /dev/null +++ b/tests/generated/.gitignore @@ -0,0 +1,2 @@ +!.gitignore +*