diff --git a/.docs/README.md b/.docs/README.md index a4babf6..ef21097 100644 --- a/.docs/README.md +++ b/.docs/README.md @@ -10,6 +10,7 @@ - [Date/time inputs](#date-time-inputs) (DateTimeInput, DateInput, TimeInput) - Captcha - [Wordcha](#wordcha) (Question-based captcha) + - [Seznam Captcha](#seznam-captcha) (Image-based captcha) ## Setup @@ -417,3 +418,56 @@ protected function createComponentForm() #### Wordcha Example ![captcha](wordcha.png) + +### Seznam Captcha + +Image-based captcha using [Seznam.cz Captcha](https://captcha.seznam.cz) service. + +#### Seznam Captcha Setup + +Register extension + +```yaml +extensions: + seznamCaptcha: Contributte\Forms\Captcha\Seznam\DI\SeznamCaptchaExtension +``` + +#### Seznam Captcha Configuration + +```yaml +seznamCaptcha: + auto: true # Automatically bind addSeznamCaptcha to forms +``` + +#### Seznam Captcha Form Usage + +```php +use Nette\Application\UI\Form; + +protected function createComponentForm() +{ + $form = new Form(); + + $form->addSeznamCaptcha('captcha') + ->getCode() + ->setRequired('Please enter the captcha code'); + + $form->addSubmit('send'); + + $form->onValidate[] = function (Form $form) { + if ($form['captcha']->verify() !== TRUE) { + $form->addError('Are you robot?'); + } + }; + + $form->onSuccess[] = function (Form $form) { + dump($form['captcha']); + }; + + return $form; +} +``` + +#### Seznam Captcha Example + +![captcha](seznam-captcha.png) diff --git a/.docs/seznam-captcha.png b/.docs/seznam-captcha.png new file mode 100644 index 0000000..670d057 Binary files /dev/null and b/.docs/seznam-captcha.png differ diff --git a/src/Captcha/Seznam/Backend/Client.php b/src/Captcha/Seznam/Backend/Client.php new file mode 100644 index 0000000..e97ce99 --- /dev/null +++ b/src/Captcha/Seznam/Backend/Client.php @@ -0,0 +1,34 @@ +serverHostname = $hostname; + $this->serverPort = $port; + } + + abstract public function create(): string; + + abstract public function getImage(string $hash): string; + + abstract public function check(string $hash, string $code): bool; + + public function setProxy(string $hostname, int $port): void + { + $this->proxyHostname = $hostname; + $this->proxyPort = $port; + } + +} diff --git a/src/Captcha/Seznam/Backend/HttpClient.php b/src/Captcha/Seznam/Backend/HttpClient.php new file mode 100644 index 0000000..416b57e --- /dev/null +++ b/src/Captcha/Seznam/Backend/HttpClient.php @@ -0,0 +1,74 @@ +call('captcha.create'); + + if ($result['status'] !== 200 || $result['data'] === false) { + throw new RuntimeException(sprintf('Captcha create failed: %s', print_r($result, true))); + } + + return $result['data']; + } + + public function getImage(string $hash): string + { + return sprintf( + 'https://%s:%d/%s?%s', + $this->serverHostname, + $this->serverPort, + 'captcha.getImage', + http_build_query(['hash' => $hash]) + ); + } + + public function check(string $hash, string $code): bool + { + $result = $this->call('captcha.check', ['hash' => $hash, 'code' => $code]); + + if (!in_array($result['status'], [200, 402, 403, 404], true)) { + throw new RuntimeException(sprintf('Captcha check failed: %s', print_r($result, true))); + } + + return $result['status'] === 200; + } + + /** + * @param array $params + * @return array{status: int, data: string|false} + */ + protected function call(string $methodName, array $params = []): array + { + $url = sprintf('https://%s:%d/%s?%s', $this->serverHostname, $this->serverPort, $methodName, http_build_query($params)); + $ch = curl_init($url); + + if ($ch === false) { + throw new RuntimeException('Failed to initialize curl'); + } + + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + if ($this->proxyHostname !== null) { + curl_setopt($ch, CURLOPT_PROXY, $this->proxyHostname); + curl_setopt($ch, CURLOPT_PROXYPORT, $this->proxyPort); + } + + /** @var string|false $response */ + $response = curl_exec($ch); + $info = curl_getinfo($ch); + curl_close($ch); + + return [ + 'status' => $info['http_code'], + 'data' => $response, + ]; + } + +} diff --git a/src/Captcha/Seznam/DI/FormBinder.php b/src/Captcha/Seznam/DI/FormBinder.php new file mode 100644 index 0000000..9158d03 --- /dev/null +++ b/src/Captcha/Seznam/DI/FormBinder.php @@ -0,0 +1,20 @@ + $container[$name] = new SeznamCaptchaContainer($factory) // @phpcs:ignore + ); + } + +} diff --git a/src/Captcha/Seznam/DI/SeznamCaptchaExtension.php b/src/Captcha/Seznam/DI/SeznamCaptchaExtension.php new file mode 100644 index 0000000..dd8d434 --- /dev/null +++ b/src/Captcha/Seznam/DI/SeznamCaptchaExtension.php @@ -0,0 +1,54 @@ + Expect::bool()->default(true), + ]); + } + + public function loadConfiguration(): void + { + $builder = $this->getContainerBuilder(); + + $client = $builder->addDefinition($this->prefix('client')) + ->setFactory(HttpClient::class, ['captcha.seznam.cz', 443]); + + $builder->addDefinition($this->prefix('factory')) + ->setType(Factory::class) + ->setFactory(SeznamFactory::class, [$client]); + } + + public function afterCompile(ClassType $class): void + { + if ($this->config->auto === true) { + $method = $class->getMethod('initialize'); + $method->addBody( + '?::bind($this->getService(?));', + [ + new Literal(FormBinder::class), + $this->prefix('factory'), + ] + ); + } + } + +} diff --git a/src/Captcha/Seznam/Exception/RuntimeException.php b/src/Captcha/Seznam/Exception/RuntimeException.php new file mode 100644 index 0000000..a63a0b8 --- /dev/null +++ b/src/Captcha/Seznam/Exception/RuntimeException.php @@ -0,0 +1,8 @@ +provider = $factory->createProvider(); + $this->validator = $factory->createValidator(); + + $imageControl = new class ('Captcha') extends BaseControl { + + private string $imageUrl = ''; + + public function __construct(string $label) + { + parent::__construct($label); + + $this->control = Html::el('img'); + $this->control->addClass('captcha-image seznam-captcha-image'); + } + + public function setImageUrl(string $url): void + { + $this->imageUrl = $url; + } + + public function getControl(): Html + { + $img = parent::getControl(); + assert($img instanceof Html); + + $img->addAttributes(['src' => $this->imageUrl]); + + return $img; + } + + }; + $imageControl->setImageUrl($this->provider->getImage()); + + $codeInput = new TextInput('Code', 5); + $codeInput->getControlPrototype()->addClass('captcha-input seznam-captcha-input'); + + $hashField = new HiddenField($this->provider->getHash()); + + $this['image'] = $imageControl; + $this['code'] = $codeInput; + $this['hash'] = $hashField; + } + + public function getImage(): BaseControl + { + $control = $this->getComponent('image'); + assert($control instanceof BaseControl); + + return $control; + } + + public function getCode(): TextInput + { + $control = $this->getComponent('code'); + assert($control instanceof TextInput); + + return $control; + } + + public function getHash(): HiddenField + { + $control = $this->getComponent('hash'); + assert($control instanceof HiddenField); + + return $control; + } + + public function verify(): bool + { + /** @var Form $form */ + $form = $this->getForm(); + + /** @var string $hash */ + $hash = $form->getHttpData(Form::DataLine, $this->getHash()->getHtmlName()); + + /** @var string $code */ + $code = $form->getHttpData(Form::DataLine, $this->getCode()->getHtmlName()); + + return $this->validator->validate($code, $hash); + } + + public function getValidator(): Validator + { + return $this->validator; + } + + public function getProvider(): Provider + { + return $this->provider; + } + +} diff --git a/src/Captcha/Seznam/Provider/Provider.php b/src/Captcha/Seznam/Provider/Provider.php new file mode 100644 index 0000000..a0b820e --- /dev/null +++ b/src/Captcha/Seznam/Provider/Provider.php @@ -0,0 +1,14 @@ +client = $client; + $this->hash = $client->create(); + } + + public function getHash(): string + { + return $this->hash; + } + + public function getImage(): string + { + return $this->client->getImage($this->hash); + } + + public function check(string $code, string $hash): bool + { + return $this->client->check($hash, $code); + } + +} diff --git a/src/Captcha/Seznam/SeznamFactory.php b/src/Captcha/Seznam/SeznamFactory.php new file mode 100644 index 0000000..ee32fe7 --- /dev/null +++ b/src/Captcha/Seznam/SeznamFactory.php @@ -0,0 +1,31 @@ +client = $client; + } + + public function createValidator(): Validator + { + return new SeznamValidator($this->createProvider()); + } + + public function createProvider(): Provider + { + return new SeznamProvider($this->client); + } + +} diff --git a/src/Captcha/Seznam/Validator/SeznamValidator.php b/src/Captcha/Seznam/Validator/SeznamValidator.php new file mode 100644 index 0000000..cd13b86 --- /dev/null +++ b/src/Captcha/Seznam/Validator/SeznamValidator.php @@ -0,0 +1,22 @@ +provider = $provider; + } + + public function validate(string $code, string $hash): bool + { + return $this->provider->check($code, $hash); + } + +} diff --git a/src/Captcha/Seznam/Validator/Validator.php b/src/Captcha/Seznam/Validator/Validator.php new file mode 100644 index 0000000..433ff60 --- /dev/null +++ b/src/Captcha/Seznam/Validator/Validator.php @@ -0,0 +1,10 @@ +shouldReceive('getHash') + ->andReturn($hash) + ->shouldReceive('getImage') + ->andReturn($imageUrl) + ->getMock(); + + $factory = Mockery::mock(Factory::class) + ->shouldReceive('createValidator') + ->andReturn($validator) + ->shouldReceive('createProvider') + ->andReturn($provider) + ->getMock(); + + FormBinder::bind($factory); + + $form = new Form(); + $captcha = $form->addSeznamCaptcha(); + + Assert::type(SeznamCaptchaContainer::class, $captcha); + Assert::type(BaseControl::class, $captcha['image']); + Assert::type(TextInput::class, $captcha['code']); + Assert::type(HiddenField::class, $captcha['hash']); + + Assert::equal($hash, $captcha['hash']->getValue()); +}); diff --git a/tests/Cases/Seznam/Form/SeznamCaptchaContainer.phpt b/tests/Cases/Seznam/Form/SeznamCaptchaContainer.phpt new file mode 100644 index 0000000..f8f0997 --- /dev/null +++ b/tests/Cases/Seznam/Form/SeznamCaptchaContainer.phpt @@ -0,0 +1,97 @@ +shouldReceive('getHash') + ->andReturn($hash) + ->shouldReceive('getImage') + ->andReturn($imageUrl) + ->getMock(); + + $factory = Mockery::mock(Factory::class) + ->shouldReceive('createValidator') + ->andReturn($validator) + ->shouldReceive('createProvider') + ->andReturn($provider) + ->getMock(); + + $captcha = new SeznamCaptchaContainer($factory); + + $form = new Form(); + $form['captcha'] = $captcha; + + Assert::type(BaseControl::class, $captcha['image']); + Assert::type(TextInput::class, $captcha['code']); + Assert::type(HiddenField::class, $captcha['hash']); + + Assert::equal($hash, $captcha->getHash()->getValue()); + Assert::contains($imageUrl, (string) $captcha->getImage()->getControl()); +}); + +Toolkit::test(function (): void { + $validator = Mockery::mock(Validator::class) + ->shouldReceive('validate') + ->andReturn(true) + ->getMock(); + + $provider = Mockery::mock(Provider::class) + ->shouldReceive('getHash') + ->andReturn('hash') + ->shouldReceive('getImage') + ->andReturn('image') + ->getMock(); + + $factory = Mockery::mock(Factory::class) + ->shouldReceive('createValidator') + ->andReturn($validator) + ->shouldReceive('createProvider') + ->andReturn($provider) + ->getMock(); + + $captcha = new SeznamCaptchaContainer($factory); + + Assert::true($captcha->getValidator()->validate('foo', 'bar')); +}); + +Toolkit::test(function (): void { + $validator = Mockery::mock(Validator::class) + ->shouldReceive('validate') + ->andReturn(false) + ->getMock(); + + $provider = Mockery::mock(Provider::class) + ->shouldReceive('getHash') + ->andReturn('hash') + ->shouldReceive('getImage') + ->andReturn('image') + ->getMock(); + + $factory = Mockery::mock(Factory::class) + ->shouldReceive('createValidator') + ->andReturn($validator) + ->shouldReceive('createProvider') + ->andReturn($provider) + ->getMock(); + + $captcha = new SeznamCaptchaContainer($factory); + + Assert::false($captcha->getValidator()->validate('foo', 'bar')); +});