diff --git a/inc/config.php b/inc/config.php index 319bb8fd..b4fc27d8 100644 --- a/inc/config.php +++ b/inc/config.php @@ -351,39 +351,39 @@ //); $config['simple_spam'] = false; - // Enable reCaptcha to make spam even harder. Rarely necessary. - $config['recaptcha'] = false; - - // Public and private key pair from https://www.google.com/recaptcha/admin/create - $config['recaptcha_public'] = '6LcXTcUSAAAAAKBxyFWIt2SO8jwx4W7wcSMRoN3f'; - $config['recaptcha_private'] = '6LcXTcUSAAAAAOGVbVdhmEM1_SyRF4xTKe8jbzf_'; - - // Enable hCaptcha as an alternative to reCAPTCHA. - $config['hcaptcha'] = false; - - // Public and private key pair for using hCaptcha. - $config['hcaptcha_public'] = '7a4b21e0-dc53-46f2-a9f8-91d2e74b63a0'; - $config['hcaptcha_private'] = '0x4e9A01bE637b51dC41a7Ea9865C3fDe4aB72Cf17'; - - // Enable Custom Captcha you need to change a couple of settings - //Read more at: /inc/captcha/readme.md - $config['captcha'] = array(); - - // Enable custom captcha provider - $config['captcha']['enabled'] = false; - - //New thread captcha - //Require solving a captcha to post a thread. - //Default off. - $config['new_thread_capt'] = false; - - // Custom captcha get provider path (if not working get the absolute path aka your url.) - $config['captcha']['provider_get'] = '../inc/captcha/entrypoint.php'; - // Custom captcha check provider path - $config['captcha']['provider_check'] = '../inc/captcha/entrypoint.php'; - - // Custom captcha extra field (eg. charset) - $config['captcha']['extra'] = 'abcdefghijklmnopqrstuvwxyz'; + $config['captcha'] = [ + // Can be false, 'recaptcha', 'hcaptcha' or 'native'. + 'provider' => false, + /* + * If not false, the captcha is dynamically injected on the client if the web server set the `captcha-required` + * cookie to 1. The configuration value should be set the IP for which the captcha should be verified. + * + * Example: + * + * // Verify the captcha for users sending posts from the loopback address. + * $config['captcha']['dynamic'] = '127.0.0.1'; + */ + 'dynamic' => false, + 'recaptcha' => [ + 'sitekey' => '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI', + 'secret' => '6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe', + ], + 'hcaptcha' => [ + 'sitekey' => '10000000-ffff-ffff-ffff-000000000001', + 'secret' => '0x0000000000000000000000000000000000000000', + ], + // To enable the native captcha you need to change a couple of settings. Read more at: /inc/captcha/readme.md + 'native' => [ + // Custom captcha get provider path (if not working get the absolute path aka your url). + 'provider_get' => '../inc/captcha/entrypoint.php', + // Custom captcha check provider path + 'provider_check' => '../inc/captcha/entrypoint.php', + // Custom captcha extra field (eg. charset) + 'extra' => 'abcdefghijklmnopqrstuvwxyz', + // New thread captcha. Require solving a captcha to post a thread. + 'new_thread_capt' => false + ] + ]; // Ability to lock a board for normal users and still allow mods to post. Could also be useful for making an archive board $config['board_locked'] = false; diff --git a/inc/context.php b/inc/context.php index f65c3b0d..3f8a75d6 100644 --- a/inc/context.php +++ b/inc/context.php @@ -3,6 +3,10 @@ namespace Vichan; use RuntimeException; use Vichan\Driver\{HttpDriver, HttpDrivers, Log, LogDrivers}; +use Vichan\Service\HCaptchaQuery; +use Vichan\Service\NativeCaptchaQuery; +use Vichan\Service\ReCaptchaQuery; +use Vichan\Service\RemoteCaptchaQuery; defined('TINYBOARD') or exit; @@ -53,6 +57,34 @@ function build_context(array $config): Context { HttpDriver::class => function($c) { $config = $c->get('config'); return HttpDrivers::getHttpDriver($config['upload_by_url_timeout'], $config['max_filesize']); + }, + RemoteCaptchaQuery::class => function($c) { + $config = $c->get('config'); + $http = $c->get(HttpDriver::class); + switch ($config['captcha']['provider']) { + case 'recaptcha': + return new ReCaptchaQuery($http, $config['captcha']['recaptcha']['secret']); + case 'hcaptcha': + return new HCaptchaQuery( + $http, + $config['captcha']['hcaptcha']['secret'], + $config['captcha']['hcaptcha']['sitekey'] + ); + default: + throw new RuntimeException('No remote captcha service available'); + } + }, + NativeCaptchaQuery::class => function($c) { + $config = $c->get('config'); + if ($config['captcha']['provider'] !== 'native') { + throw new RuntimeException('No native captcha service available'); + } + return new NativeCaptchaQuery( + $c->get(HttpDriver::class), + $config['domain'], + $config['captcha']['native']['provider_check'], + $config['captcha']['native']['extra'] + ); } ]); } diff --git a/inc/service/captcha-queries.php b/inc/service/captcha-queries.php index d7966501..cd9a1b84 100644 --- a/inc/service/captcha-queries.php +++ b/inc/service/captcha-queries.php @@ -6,95 +6,136 @@ use Vichan\Driver\HttpDriver; defined('TINYBOARD') or exit; -class RemoteCaptchaQuery { +class ReCaptchaQuery implements RemoteCaptchaQuery { private HttpDriver $http; private string $secret; - private string $endpoint; - /** - * Creates a new CaptchaRemoteQueries instance using the google recaptcha service. + * Creates a new ReCaptchaQuery using the google recaptcha service. * * @param HttpDriver $http The http client. * @param string $secret Server side secret. - * @return CaptchaRemoteQueries A new captcha query instance. + * @return ReCaptchaQuery A new ReCaptchaQuery query instance. */ - public static function withRecaptcha(HttpDriver $http, string $secret): RemoteCaptchaQuery { - return new self($http, $secret, 'https://www.google.com/recaptcha/api/siteverify'); - } - - /** - * Creates a new CaptchaRemoteQueries instance using the hcaptcha service. - * - * @param HttpDriver $http The http client. - * @param string $secret Server side secret. - * @return CaptchaRemoteQueries A new captcha query instance. - */ - public static function withHCaptcha(HttpDriver $http, string $secret): RemoteCaptchaQuery { - return new self($http, $secret, 'https://hcaptcha.com/siteverify'); - } - - private function __construct(HttpDriver $http, string $secret, string $endpoint) { + public function __construct(HttpDriver $http, string $secret) { $this->http = $http; $this->secret = $secret; - $this->endpoint = $endpoint; } - /** - * Checks if the user at the remote ip passed the captcha. - * - * @param string $response User provided response. - * @param string $remote_ip User ip. - * @return bool Returns true if the user passed the captcha. - * @throws RuntimeException|JsonException Throws on IO errors or if it fails to decode the answer. - */ - public function verify(string $response, string $remote_ip): bool { - $data = array( - 'secret' => $this->secret, - 'response' => $response, - 'remoteip' => $remote_ip - ); + public function responseField(): string { + return 'g-recaptcha-response'; + } - $ret = $this->http->requestGet($this->endpoint, $data); + public function verify(string $response, ?string $remote_ip): bool { + $data = [ + 'secret' => $this->secret, + 'response' => $response + ]; + + if ($remote_ip !== null) { + $data['remoteip'] = $remote_ip; + } + + $ret = $this->http->requestGet('https://www.google.com/recaptcha/api/siteverify', $data); $resp = json_decode($ret, true, 16, JSON_THROW_ON_ERROR); return isset($resp['success']) && $resp['success']; } } +class HCaptchaQuery implements RemoteCaptchaQuery { + private HttpDriver $http; + private string $secret; + private string $sitekey; + + /** + * Creates a new HCaptchaQuery using the hCaptcha service. + * + * @param HttpDriver $http The http client. + * @param string $secret Server side secret. + * @return HCaptchaQuery A new hCaptcha query instance. + */ + public function __construct(HttpDriver $http, string $secret, string $sitekey) { + $this->http = $http; + $this->secret = $secret; + $this->sitekey = $sitekey; + } + + public function responseField(): string { + return 'h-captcha-response'; + } + + public function verify(string $response, ?string $remote_ip): bool { + $data = [ + 'secret' => $this->secret, + 'response' => $response, + 'sitekey' => $this->sitekey + ]; + + if ($remote_ip !== null) { + $data['remoteip'] = $remote_ip; + } + + $ret = $this->http->requestGet('https://hcaptcha.com/siteverify', $data); + $resp = json_decode($ret, true, 16, JSON_THROW_ON_ERROR); + + return isset($resp['success']) && $resp['success']; + } +} + +interface RemoteCaptchaQuery { + /** + * Name of the response field in the form data expected by the implementation. + * + * @return string The name of the field. + */ + public function responseField(): string; + + /** + * Checks if the user at the remote ip passed the captcha. + * + * @param string $response User provided response. + * @param ?string $remote_ip User ip. Leave to null to only check the response value. + * @return bool Returns true if the user passed the captcha. + * @throws RuntimeException|JsonException Throws on IO errors or if it fails to decode the answer. + */ + public function verify(string $response, ?string $remote_ip): bool; +} + class NativeCaptchaQuery { private HttpDriver $http; private string $domain; private string $provider_check; - + private string $extra; /** * @param HttpDriver $http The http client. * @param string $domain The server's domain. * @param string $provider_check Path to the endpoint. + * @param string $extra Extra http parameters. */ - function __construct(HttpDriver $http, string $domain, string $provider_check) { + function __construct(HttpDriver $http, string $domain, string $provider_check, string $extra) { $this->http = $http; $this->domain = $domain; $this->provider_check = $provider_check; + $this->extra = $extra; } /** * Checks if the user at the remote ip passed the native vichan captcha. * - * @param string $extra Extra http parameters. * @param string $user_text Remote user's text input. * @param string $user_cookie Remote user cookie. * @return bool Returns true if the user passed the check. * @throws RuntimeException Throws on IO errors. */ - public function verify(string $extra, string $user_text, string $user_cookie): bool { - $data = array( + public function verify(string $user_text, string $user_cookie): bool { + $data = [ 'mode' => 'check', 'text' => $user_text, - 'extra' => $extra, + 'extra' => $this->extra, 'cookie' => $user_cookie - ); + ]; $ret = $this->http->requestGet($this->domain . '/' . $this->provider_check, $data); return $ret === '1'; diff --git a/post.php b/post.php index 1ce10fba..1f3a2b97 100644 --- a/post.php +++ b/post.php @@ -629,10 +629,15 @@ if (isset($_POST['delete'])) { // Check for CAPTCHA right after opening the board so the "return" link is in there. try { + $provider = $config['captcha']['provider']; + $new_thread_capt = $config['captcha']['native']['new_thread_capt']; + $dynamic = $config['captcha']['dynamic']; + // With our custom captcha provider - if ($config['captcha']['enabled'] || ($post['op'] && $config['new_thread_capt'])) { - $query = new NativeCaptchaQuery($context->get(HttpDriver::class), $config['domain'], $config['captcha']['provider_check']); - $success = $query->verify($config['captcha']['extra'], $_POST['captcha_text'], $_POST['captcha_cookie']); + if (($provider === 'native' && !$new_thread_capt) + || ($provider === 'native' && $new_thread_capt && $post['op'])) { + $query = $context->get(NativeCaptchaQuery::class); + $success = $query->verify($_POST['captcha_text'], $_POST['captcha_cookie']); if (!$success) { error( @@ -648,29 +653,23 @@ if (isset($_POST['delete'])) { } } // Remote 3rd party captchas. - else { - // recaptcha - if ($config['recaptcha']) { - if (!isset($_POST['g-recaptcha-response'])) { - error($config['error']['bot']); - } - $response = $_POST['g-recaptcha-response']; - $query = RemoteCaptchaQuery::withRecaptcha($context->get(HttpDriver::class), $config['recaptcha_private']); - } - // hCaptcha - elseif ($config['hcaptcha']) { - if (!isset($_POST['h-captcha-response'])) { - error($config['error']['bot']); - } - $response = $_POST['h-captcha-response']; - $query = RemoteCaptchaQuery::withHCaptcha($context->get(HttpDriver::class), $config['hcaptcha_private']); - } + elseif ($provider && (!$dynamic || $dynamic === $_SERVER['REMOTE_ADDR'])) { + $query = $content->get(RemoteCaptchaQuery::class); + $field = $query->responseField(); - if (isset($query, $response)) { - $success = $query->verify($response, $_SERVER['REMOTE_ADDR']); - if (!$success) { - error($config['error']['captcha']); - } + if (!isset($_POST[$field])) { + error($config['error']['bot']); + } + $response = $_POST[$field]; + /* + * Do not query with the IP if the mode is dynamic. This config is meant for proxies and internal + * loopback addresses. + */ + $ip = $dynamic ? null : $_SERVER['REMOTE_ADDR']; + + $success = $query->verify($response, $ip); + if (!$success) { + error($config['error']['captcha']); } } } catch (RuntimeException $e) { diff --git a/templates/header.html b/templates/header.html index d35fabb9..14a42952 100644 --- a/templates/header.html +++ b/templates/header.html @@ -1,55 +1,55 @@ - - {% if config.url_favicon %}{% endif %} - - - {% if config.meta_keywords %}{% endif %} - {% if config.default_stylesheet.1 != '' %}{% endif %} - {% if config.font_awesome %}{% endif %} - {% if config.country_flags_condensed %}{% endif %} - - {% if not nojavascript %} - - {% if not config.additional_javascript_compile %} - {% for javascript in config.additional_javascript %}{% endfor %} - {% endif %} - {% if mod %} - - {% endif %} - {% endif %} - {% if config.recaptcha %} - {% endif %} - {% if config.hcaptcha %} - - {% endif %} + +{% if config.url_favicon %}{% endif %} + + +{% if config.meta_keywords %}{% endif %} +{% if config.default_stylesheet.1 != '' %}{% endif %} +{% if config.font_awesome %}{% endif %} +{% if config.country_flags_condensed %}{% endif %} + +{% if not nojavascript %} + + {% if not config.additional_javascript_compile %} + {% for javascript in config.additional_javascript %}{% endfor %} + {% endif %} + {% if mod %} + + {% endif %} +{% endif %} +{% if config.captcha.provider == 'recaptcha' %} +{% endif %} +{% if config.captcha.provider.hcaptcha %} + +{% endif %} diff --git a/templates/main.js b/templates/main.js index a850787f..f956f5cf 100644 --- a/templates/main.js +++ b/templates/main.js @@ -222,6 +222,36 @@ function getCookie(cookie_name) { } } +{% endraw %} +{% if config.captcha.dynamic %} +function is_dynamic_captcha_enabled() { + let cookie = get_cookie('require-captcha'); + return cookie === '1'; +} + +function get_captcha_pub_key() { +{% if config.captcha.provider === 'recaptcha' %} + return "{{ config.captcha.recaptcha.sitekey }}"; +{% else %} + return null; +{% endif %} +} + +function init_dynamic_captcha() { + if (!is_dynamic_captcha_enabled()) { + let pub_key = get_captcha_pub_key(); + if (!pub_key) { + console.error("Missing public captcha key!"); + return; + } + + let captcha_hook = document.getElementById('captcha'); + captcha_hook.style = ""; + } +} +{% endif %} +{% raw %} + function highlightReply(id) { if (typeof window.event != "undefined" && event.which == 2) { // don't highlight on middle click diff --git a/templates/post_form.html b/templates/post_form.html index 5a6854cf..038395fa 100644 --- a/templates/post_form.html +++ b/templates/post_form.html @@ -72,26 +72,30 @@ {% endif %} - {% if config.recaptcha %} + {% if config.captcha.provider == 'recaptcha' %} + {% if config.captcha.dynamic %} +