diff --git a/Sources/Actions/Admin/AntiSpam.php b/Sources/Actions/Admin/AntiSpam.php index 68ee4b5b79..1d8153fdba 100644 --- a/Sources/Actions/Admin/AntiSpam.php +++ b/Sources/Actions/Admin/AntiSpam.php @@ -18,13 +18,10 @@ use SMF\ActionInterface; use SMF\ActionTrait; use SMF\BackwardCompatibility; -use SMF\Cache\CacheApi; use SMF\Config; -use SMF\Db\DatabaseApi as Db; use SMF\IntegrationHook; use SMF\Lang; use SMF\Menu; -use SMF\Theme; use SMF\User; use SMF\Utils; @@ -50,78 +47,6 @@ public function execute(): void // You need to be an admin to edit settings! User::$me->isAllowedTo('admin_forum'); - // Generate a sample registration image. - Utils::$context['verification_image_href'] = Config::$scripturl . '?action=verificationcode;rand=' . bin2hex(random_bytes(16)); - - // Firstly, figure out what languages we're dealing with, and do a little processing for the form's benefit. - Lang::get(); - Utils::$context['qa_languages'] = []; - - foreach (Utils::$context['languages'] as $lang_id => $lang) { - $lang_id = strtr($lang_id, ['-utf8' => '']); - $lang['name'] = strtr($lang['name'], ['-utf8' => '']); - Utils::$context['qa_languages'][$lang_id] = $lang; - } - - // Secondly, load any questions we currently have. - Utils::$context['question_answers'] = []; - - $request = Db::$db->query( - 'SELECT id_question, lngfile, question, answers - FROM {db_prefix}qanda', - ); - - while ($row = Db::$db->fetch_assoc($request)) { - $lang = strtr($row['lngfile'], ['-utf8' => '']); - - Utils::$context['question_answers'][$row['id_question']] = [ - 'lngfile' => $lang, - 'question' => $row['question'], - 'answers' => (array) Utils::jsonDecode($row['answers'], true), - ]; - - Utils::$context['qa_by_lang'][$lang][] = $row['id_question']; - } - Db::$db->free_result($request); - - if (empty(Utils::$context['qa_by_lang'][strtr(Lang::$default, ['-utf8' => ''])]) && !empty(Utils::$context['question_answers'])) { - if (empty(Utils::$context['settings_insert_above'])) { - Utils::$context['settings_insert_above'] = ''; - } - - Utils::$context['settings_insert_above'] .= '
' . Lang::getTxt('question_not_defined', Utils::$context['languages'][Lang::$default], file: 'ManageSettings') . '
'; - } - - // Thirdly, push some JavaScript for the form to make it work. - $nextrow = !empty(Utils::$context['question_answers']) ? max(array_keys(Utils::$context['question_answers'])) + 1 : 1; - $setup_verification_add_answer = Utils::escapeJavaScript(Lang::getTxt('setup_verification_add_answer', file: 'ManageSettings')); - $default_lang = strtr(Lang::$default, ['-utf8' => '']); - - Theme::addInlineJavaScript(<<
').insertBefore($(this).parent()); - nextrow++; - }); - $(".qa_fieldset ").on("click", ".qa_add_answer a", function() { - var attr = $(this).closest("dd").find(".verification_answer:last").attr("name"); - $('').insertBefore($(this).closest("div")); - return false; - }); - $("#qa_dt_{$default_lang} a").click(); - END, true); - // Saving? if (isset($_GET['save'])) { User::$me->checkSession(); @@ -140,143 +65,8 @@ public function execute(): void $save_vars[] = ['text', 'pm_spam_settings']; - // Handle verification questions. - $changes = [ - 'insert' => [], - 'replace' => [], - 'delete' => [], - ]; - - $qs_per_lang = []; - - foreach (Utils::$context['qa_languages'] as $lang_id => $dummy) { - // If we had some questions for this language before, but don't now, delete everything from that language. - if ((!isset($_POST['question'][$lang_id]) || !\is_array($_POST['question'][$lang_id])) && !empty(Utils::$context['qa_by_lang'][$lang_id])) { - $changes['delete'] = array_merge($changes['delete'], Utils::$context['qa_by_lang'][$lang_id]); - } - - // Now step through and see if any existing questions no longer exist. - if (!empty(Utils::$context['qa_by_lang'][$lang_id])) { - foreach (Utils::$context['qa_by_lang'][$lang_id] as $q_id) { - if (empty($_POST['question'][$lang_id][$q_id])) { - $changes['delete'][] = $q_id; - } - } - } - - // Now let's see if there are new questions or ones that need updating. - if (isset($_POST['question'][$lang_id])) { - foreach ($_POST['question'][$lang_id] as $q_id => $question) { - // Ignore junky ids. - $q_id = (int) $q_id; - - if ($q_id <= 0) { - continue; - } - - // Check the question isn't empty (because they want to delete it?) - if (empty($question) || trim($question) == '') { - if (isset(Utils::$context['question_answers'][$q_id])) { - $changes['delete'][] = $q_id; - } - - continue; - } - - $question = Utils::htmlspecialchars(trim($question)); - - // Get the answers. Firstly check there actually might be some. - if (!isset($_POST['answer'][$lang_id][$q_id]) || !\is_array($_POST['answer'][$lang_id][$q_id])) { - if (isset(Utils::$context['question_answers'][$q_id])) { - $changes['delete'][] = $q_id; - } - - continue; - } - - // Now get them and check that they might be viable. - $answers = []; - - foreach ($_POST['answer'][$lang_id][$q_id] as $answer) { - if (!empty($answer) && trim($answer) !== '') { - $answers[] = Utils::htmlspecialchars(trim($answer)); - } - } - - if (empty($answers)) { - if (isset(Utils::$context['question_answers'][$q_id])) { - $changes['delete'][] = $q_id; - } - - continue; - } - - $answers = Utils::jsonEncode($answers); - - // At this point we know we have a question and some answers. What are we doing with it? - if (!isset(Utils::$context['question_answers'][$q_id])) { - // New question. Now, we don't want to randomly consume ids, so we'll set those, rather than trusting the browser's supplied ids. - $changes['insert'][] = [$lang_id, $question, $answers]; - } else { - // It's an existing question. Let's see what's changed, if anything. - if ($lang_id != Utils::$context['question_answers'][$q_id]['lngfile'] || $question != Utils::$context['question_answers'][$q_id]['question'] || $answers != Utils::$context['question_answers'][$q_id]['answers']) { - $changes['replace'][$q_id] = ['lngfile' => $lang_id, 'question' => $question, 'answers' => $answers]; - } - } - - if (!isset($qs_per_lang[$lang_id])) { - $qs_per_lang[$lang_id] = 0; - } - $qs_per_lang[$lang_id]++; - } - } - } - - // OK, so changes? - if (!empty($changes['delete'])) { - Db::$db->query( - 'DELETE FROM {db_prefix}qanda - WHERE id_question IN ({array_int:questions})', - [ - 'questions' => $changes['delete'], - ], - ); - } - - if (!empty($changes['replace'])) { - foreach ($changes['replace'] as $q_id => $question) { - Db::$db->query( - 'UPDATE {db_prefix}qanda - SET lngfile = {string:lngfile}, - question = {string:question}, - answers = {string:answers} - WHERE id_question = {int:id_question}', - [ - 'id_question' => $q_id, - 'lngfile' => $question['lngfile'], - 'question' => $question['question'], - 'answers' => $question['answers'], - ], - ); - } - } - - if (!empty($changes['insert'])) { - Db::$db->insert( - 'insert', - '{db_prefix}qanda', - ['lngfile' => 'string-50', 'question' => 'string-255', 'answers' => 'string-65534'], - $changes['insert'], - ['id_question'], - ); - } - - // Lastly, the count of messages needs to be no more than the lowest number of questions for any one language. - $count_questions = empty($qs_per_lang) ? 0 : min($qs_per_lang); - - if (empty($count_questions) || $_POST['qa_verification_number'] > $count_questions) { - $_POST['qa_verification_number'] = $count_questions; - } + // Process all of our config vars from various agents. + \SMF\AntiSpam\AntiSpam::saveConfigVars(); IntegrationHook::call('integrate_save_spam_settings', [&$save_vars]); @@ -284,37 +74,9 @@ public function execute(): void ACP::saveDBSettings($save_vars); $_SESSION['adm-save'] = true; - CacheApi::put('verificationQuestions', null, 300); - Utils::redirectexit('action=admin;area=antispam'); } - $character_range = array_merge(range('A', 'H'), ['K', 'M', 'N', 'P', 'R'], range('T', 'Y')); - $_SESSION['visual_verification_code'] = ''; - - for ($i = 0; $i < 6; $i++) { - $_SESSION['visual_verification_code'] .= $character_range[array_rand($character_range)]; - } - - // Some javascript for CAPTCHA. - Utils::$context['settings_post_javascript'] = ''; - - if (Utils::$context['use_graphic_library']) { - Utils::$context['settings_post_javascript'] .= ' - function refreshImages() - { - var imageType = document.getElementById(\'visual_verification_type\').value; - document.getElementById(\'verification_image\').src = \'' . Utils::$context['verification_image_href'] . ';type=\' + imageType; - }'; - } - - // Show the image itself, or text saying we can't. - if (Utils::$context['use_graphic_library']) { - $config_vars['vv']['postinput'] = '
' . Lang::getTxt('setting_image_verification_sample', file: 'ManageSettings') . '
'; - } else { - $config_vars['vv']['postinput'] = '
' . Lang::getTxt('setting_image_verification_nogd', file: 'ManageSettings') . ''; - } - // Hack for PM spam settings. list(Config::$modSettings['max_pm_recipients'], Config::$modSettings['pm_posts_verification'], Config::$modSettings['pm_posts_per_hour']) = explode(',', Config::$modSettings['pm_spam_settings']); @@ -353,9 +115,7 @@ function refreshImages() */ public static function getConfigVars(): array { - // Generate a sample registration image. - Utils::$context['use_graphic_library'] = \in_array('gd', get_loaded_extensions()); - + $agents = []; $config_vars = [ ['check', 'reg_verification'], ['check', 'search_enable_captcha'], @@ -390,60 +150,12 @@ public static function getConfigVars(): array 'pm_posts_per_hour', 'subtext' => Lang::getTxt('pm_posts_per_hour_note', file: 'ManageSettings'), ], - // Visual verification. - ['title', 'configure_verification_means'], - ['desc', 'configure_verification_means_desc'], - 'vv' => [ - 'select', - 'visual_verification_type', - [ - Lang::getTxt('setting_image_verification_off', file: 'ManageSettings'), - Lang::getTxt('setting_image_verification_vsimple', file: 'ManageSettings'), - Lang::getTxt('setting_image_verification_simple', file: 'ManageSettings'), - Lang::getTxt('setting_image_verification_medium', file: 'ManageSettings'), - Lang::getTxt('setting_image_verification_high', file: 'ManageSettings'), - Lang::getTxt('setting_image_verification_extreme', file: 'ManageSettings'), - ], - 'subtext' => Lang::getTxt('setting_visual_verification_type_desc', file: 'ManageSettings'), - 'onchange' => Utils::$context['use_graphic_library'] ? 'refreshImages();' : '', - ], - // reCAPTCHA - ['title', 'recaptcha_configure'], - ['desc', 'recaptcha_configure_desc', 'class' => 'windowbg'], - [ - 'check', - 'recaptcha_enabled', - 'subtext' => Lang::getTxt('recaptcha_enable_desc', file: 'ManageSettings'), - ], - [ - 'text', - 'recaptcha_site_key', - 'subtext' => Lang::getTxt('recaptcha_site_key_desc', file: 'ManageSettings'), - ], - [ - 'text', - 'recaptcha_secret_key', - 'subtext' => Lang::getTxt('recaptcha_secret_key_desc', file: 'ManageSettings'), - ], - [ - 'select', - 'recaptcha_theme', - [ - 'light' => Lang::getTxt('recaptcha_theme_light', file: 'ManageSettings'), - 'dark' => Lang::getTxt('recaptcha_theme_dark', file: 'ManageSettings'), - ], - ], - // Clever Thomas, who is looking sheepy now? Not I, the mighty sword swinger did say. - ['title', 'setup_verification_questions'], - ['desc', 'setup_verification_questions_desc'], - [ - 'int', - 'qa_verification_number', - 'subtext' => Lang::getTxt('setting_qa_verification_number_desc', file: 'ManageSettings'), - ], - ['callback', 'question_answer_list'], + 'antispamagents' => ['select', 'antispam_agents', &$agents, 'multiple' => true], ]; + // Process all of our config vars from various agents. + \SMF\AntiSpam\AntiSpam::getConfigVars($config_vars, $agents); + IntegrationHook::call('integrate_spam_settings', [&$config_vars]); return $config_vars; diff --git a/Sources/Actions/Display.php b/Sources/Actions/Display.php index c0b7aea94d..648e5d1687 100644 --- a/Sources/Actions/Display.php +++ b/Sources/Actions/Display.php @@ -19,6 +19,7 @@ use SMF\ActionRouter; use SMF\ActionTrait; use SMF\Alert; +use SMF\AntiSpam\Verification; use SMF\Attachment; use SMF\Board; use SMF\Cache\CacheApi; @@ -39,7 +40,6 @@ use SMF\Topic; use SMF\User; use SMF\Utils; -use SMF\Verifier; /** * This class loads the posts in a topic so they can be displayed. @@ -1161,7 +1161,7 @@ protected function setupVerification(): void Utils::$context['require_verification'] = !User::$me->is_mod && !User::$me->is_admin && !empty(Config::$modSettings['posts_require_captcha']) && (User::$me->posts < Config::$modSettings['posts_require_captcha'] || (User::$me->is_guest && Config::$modSettings['posts_require_captcha'] == -1)); if (Utils::$context['require_verification']) { - $verifier = new Verifier(['id' => 'post']); + new Verification(['id' => 'post']); } } diff --git a/Sources/Actions/Post.php b/Sources/Actions/Post.php index ba1953b354..b2450b98b8 100644 --- a/Sources/Actions/Post.php +++ b/Sources/Actions/Post.php @@ -18,6 +18,7 @@ use SMF\ActionInterface; use SMF\ActionSuffixRouter; use SMF\ActionTrait; +use SMF\AntiSpam\Verification; use SMF\Attachment; use SMF\Board; use SMF\Cache\CacheApi; @@ -40,7 +41,6 @@ use SMF\Topic; use SMF\User; use SMF\Utils; -use SMF\Verifier; /** * This class handles posting and modifying replies and new topics. @@ -1654,7 +1654,7 @@ protected function showVerification(): void Utils::$context['require_verification'] = !User::$me->is_mod && !User::$me->is_admin && !empty(Config::$modSettings['posts_require_captcha']) && (User::$me->posts < Config::$modSettings['posts_require_captcha'] || (User::$me->is_guest && Config::$modSettings['posts_require_captcha'] == -1)); if (Utils::$context['require_verification']) { - $verifier = new Verifier(['id' => 'post']); + new Verification(['id' => 'post']); } // If they came from quick reply, and have to enter verification details, give them some notice. diff --git a/Sources/Actions/Post2.php b/Sources/Actions/Post2.php index 480143d52e..d412036eaf 100644 --- a/Sources/Actions/Post2.php +++ b/Sources/Actions/Post2.php @@ -15,6 +15,7 @@ namespace SMF\Actions; +use SMF\AntiSpam\Verification; use SMF\Attachment; use SMF\Autolinker; use SMF\Board; @@ -40,7 +41,6 @@ use SMF\Topic; use SMF\User; use SMF\Utils; -use SMF\Verifier; /** * This class handles posting and modifying replies and new topics. @@ -740,8 +740,8 @@ protected function checkVerification(): void ) ) ) { - $verifier = new Verifier(['id' => 'post']); - $this->errors = array_merge($this->errors, $verifier->errors); + $verification = new Verification(['id' => 'post'], true); + $this->errors = array_merge($this->errors, $verification->errors); } } diff --git a/Sources/Actions/Register.php b/Sources/Actions/Register.php index ff8bb77e17..77d5359eba 100644 --- a/Sources/Actions/Register.php +++ b/Sources/Actions/Register.php @@ -18,6 +18,7 @@ use SMF\ActionInterface; use SMF\ActionRouter; use SMF\ActionTrait; +use SMF\AntiSpam\Verification; use SMF\Config; use SMF\ErrorHandler; use SMF\Lang; @@ -30,7 +31,6 @@ use SMF\Theme; use SMF\User; use SMF\Utils; -use SMF\Verifier; /** * Shows the registration form. @@ -318,7 +318,7 @@ public function show(): void // Generate a visual verification code to make sure the user is no bot. if (!empty(Config::$modSettings['reg_verification'])) { - $verifier = new Verifier(['id' => 'register']); + new Verification(['id' => 'register']); } // Otherwise we have nothing to show. else { diff --git a/Sources/Actions/Register2.php b/Sources/Actions/Register2.php index b8224efa72..d0034350f4 100644 --- a/Sources/Actions/Register2.php +++ b/Sources/Actions/Register2.php @@ -15,6 +15,7 @@ namespace SMF\Actions; +use SMF\AntiSpam\Verification; use SMF\Config; use SMF\Cookie; use SMF\Db\DatabaseApi as Db; @@ -36,7 +37,6 @@ use SMF\Url; use SMF\User; use SMF\Utils; -use SMF\Verifier; /** * Actually registers the new member. @@ -146,10 +146,10 @@ public function execute(): void // Check whether the visual verification code was entered correctly. if (!empty(Config::$modSettings['reg_verification'])) { - $verifier = new Verifier(['id' => 'register']); + $verification = new Verification(['id' => 'register']); - if (!empty($verifier->errors)) { - foreach ($verifier->errors as $error) { + if (!empty($verification->errors)) { + foreach ($verification->errors as $error) { $this->errors[] = Lang::getTxt('error_' . $error, [], file: 'Errors'); } } diff --git a/Sources/Actions/Search.php b/Sources/Actions/Search.php index 4427689e13..744db80054 100644 --- a/Sources/Actions/Search.php +++ b/Sources/Actions/Search.php @@ -18,6 +18,7 @@ use SMF\ActionInterface; use SMF\ActionRouter; use SMF\ActionTrait; +use SMF\AntiSpam\Verification; use SMF\Category; use SMF\Config; use SMF\Db\DatabaseApi as Db; @@ -30,7 +31,6 @@ use SMF\Theme; use SMF\User; use SMF\Utils; -use SMF\Verifier; /** * Shows the search form. @@ -89,7 +89,7 @@ public function execute(): void Utils::$context['require_verification'] = User::$me->is_guest && !empty(Config::$modSettings['search_enable_captcha']) && empty($_SESSION['ss_vv_passed']); if (Utils::$context['require_verification']) { - $verifier = new Verifier(['id' => 'search']); + new Verification(['id' => 'search']); } // If you got back from search2 by using the linktree, you get your original search parameters back. diff --git a/Sources/Actions/Search2.php b/Sources/Actions/Search2.php index ac0114fb6e..56e942f656 100644 --- a/Sources/Actions/Search2.php +++ b/Sources/Actions/Search2.php @@ -18,6 +18,7 @@ use SMF\ActionInterface; use SMF\ActionRouter; use SMF\ActionTrait; +use SMF\AntiSpam\Verification; use SMF\Config; use SMF\Db\DatabaseApi as Db; use SMF\ErrorHandler; @@ -32,7 +33,6 @@ use SMF\Theme; use SMF\User; use SMF\Utils; -use SMF\Verifier; /** * Shows the search form. @@ -442,10 +442,10 @@ protected function setupVerification(): void { // Do we have captcha enabled? if (User::$me->is_guest && !empty(Config::$modSettings['search_enable_captcha']) && empty($_SESSION['ss_vv_passed']) && (empty($_SESSION['last_ss']) || $_SESSION['last_ss'] != SearchApi::$loadedApi->params['search'])) { - $verifier = new Verifier(['id' => 'search']); + $verification = new Verification(['id' => 'search'], true); - if (!empty($verifier->errors)) { - foreach ($verifier->errors as $error) { + if (!empty($verification->errors)) { + foreach ($verification->errors as $error) { Utils::$context['search_errors'][$error] = true; } } diff --git a/Sources/Actions/VerificationCode.php b/Sources/Actions/VerificationCode.php index cbfa056b9b..6eb154a578 100644 --- a/Sources/Actions/VerificationCode.php +++ b/Sources/Actions/VerificationCode.php @@ -18,12 +18,12 @@ use SMF\ActionInterface; use SMF\ActionRouter; use SMF\ActionTrait; -use SMF\Cache\CacheApi; +use SMF\AntiSpam\AntiSpam; +use SMF\AntiSpam\APIs\ImageVerfication; use SMF\Config; use SMF\Routable; -use SMF\Theme; use SMF\User; -use SMF\Utils; +use SMF\Uuid; /** * Shows the verification code or let it be heard. @@ -35,23 +35,13 @@ class VerificationCode implements ActionInterface, Routable use ActionRouter; use ActionTrait; - /******************* - * Public properties - *******************/ + /********************* + * Internal properties + *********************/ - /** - * @var string - * - * Identifier passed in 'vid' URL parameter. - */ - public string $verification_id; + protected string $form_id; - /** - * @var string - * - * The verification code. - */ - public string $code; + protected Uuid $agent_id; /**************** * Public methods @@ -72,49 +62,18 @@ public function canBeLogged(): bool */ public function execute(): void { - // Somehow no code was generated or the session was lost. - if (empty($this->code)) { - header('content-type: image/gif'); - - die("\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00\x21\xF9\x04\x01\x00\x00\x00\x00\x2C\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00\x3B"); + // Random image for testing? + if (User::$me->is_admin && isset($_GET['rand'], $_GET['agent'])) { + [$this->form_id, $this->agent_id] = AntiSpam::setupTestCode($_GET['rand'], $_GET['agent']); } - - // Show a window that will play the verification code. - if (isset($_REQUEST['sound'])) { - Theme::loadTemplate('Register'); - - Utils::$context['verification_sound_href'] = Config::$scripturl . '?action=verificationcode;rand=' . bin2hex(random_bytes(16)) . ($this->verification_id ? ';vid=' . $this->verification_id : '') . ';format=.wav'; - Utils::$context['sub_template'] = 'verification_sound'; - Utils::$context['template_layers'] = []; - - Utils::obExit(); + // Ensure some backwards compatbility and render a ImageVerification if we didn't specify an agent. + elseif (Config::$backward_compatibility && empty($this->agent_id)) { + $_SESSION[$this->form_id . '_vv'] ??= []; + $_SESSION[$this->form_id . '_vv']['agents'] ??= []; + $_SESSION[$this->form_id . '_vv']['agents'][(string) Uuid::create()] = basename(ImageVerfication::class); } - // If we have GD, try the nice code. - elseif (empty($_REQUEST['format'])) { - if (\extension_loaded('gd') && !$this->showCodeImage($this->code)) { - Utils::sendHttpStatus(400); - } - // Otherwise just show a pre-defined letter. - elseif (isset($_REQUEST['letter'])) { - $_REQUEST['letter'] = (int) $_REQUEST['letter']; - - if ($_REQUEST['letter'] > 0 && $_REQUEST['letter'] <= \strlen($this->code) && !$this->showLetterImage(strtolower($this->code[$_REQUEST['letter'] - 1]))) { - header('content-type: image/gif'); - die("\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00\x21\xF9\x04\x01\x00\x00\x00\x00\x2C\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00\x3B"); - } - } - // You must be up to no good. - else { - header('content-type: image/gif'); - - die("\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\x00\x00\x00\x21\xF9\x04\x01\x00\x00\x00\x00\x2C\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00\x3B"); - } - } elseif ($_REQUEST['format'] === '.wav') { - if (!$this->createWaveFile($this->code)) { - Utils::sendHttpStatus(400); - } - } + AntiSpam::showCode($this->form_id, $this->agent_id); // We all die one day... die(); @@ -129,689 +88,7 @@ public function execute(): void */ protected function __construct() { - $this->verification_id = $_GET['vid'] ?? ''; - - $this->code = $this->verification_id && isset($_SESSION[$this->verification_id . '_vv'], $_SESSION[$this->verification_id . '_vv']['code']) ? $_SESSION[$this->verification_id . '_vv']['code'] : ($_SESSION['visual_verification_code'] ?? ''); - } - - /** - * Show an image containing the visual verification code for registration. - * - * Requires the GD extension. - * Uses a random font for each letter from default_theme_dir/fonts. - * Outputs a gif or a png (depending on whether gif ix supported). - * - * @param string $code The code to display - * @return bool False if something goes wrong. Otherwise, dies. - */ - protected function showCodeImage(string $code): bool - { - if (!\extension_loaded('gd')) { - return false; - } - - // Note: The higher the value of visual_verification_type the harder the verification is - from 0 as disabled through to 4 as "Very hard". - - // What type are we going to be doing? - $image_type = Config::$modSettings['visual_verification_type']; - - // Special case to allow the admin center to show samples. - if (User::$me->is_admin && isset($_GET['type'])) { - $image_type = (int) $_GET['type']; - } - - // Some quick references for what we do. - // Do we show no, low or high noise? - $noise_type = $image_type == 3 ? 'low' : ($image_type == 4 ? 'high' : ($image_type == 5 ? 'extreme' : 'none')); - - // Can we have more than one font in use? - $vary_fonts = $image_type > 3 ? true : false; - - // Just a plain white background? - $simple_bg_color = $image_type < 3 ? true : false; - - // Plain black foreground? - $simple_fg_color = $image_type == 0 ? true : false; - - // High much to rotate each character. - $rotation_type = $image_type == 1 ? 'none' : ($image_type > 3 ? 'low' : 'high'); - - // Do we show some characters inversed? - $show_reverse_chars = $image_type > 3 ? true : false; - - // Special case for not showing any characters. - $disable_chars = $image_type == 0 ? true : false; - - // What do we do with the font colors. Are they one color, close to one color or random? - $font_color_type = $image_type == 1 ? 'plain' : ($image_type > 3 ? 'random' : 'cyclic'); - - // Are the fonts random sizes? - $font_size_random = $image_type > 3 ? true : false; - - // How much space between characters? - $font_horz_space = $image_type > 3 ? 'high' : ($image_type == 1 ? 'medium' : 'minus'); - - // Where do characters sit on the image? (Fixed position or random/very random) - $font_vert_pos = $image_type == 1 ? 'fixed' : ($image_type > 3 ? 'vrandom' : 'random'); - - // Make font semi-transparent? - $font_transparent = $image_type == 2 || $image_type == 3 ? true : false; - - // Give the image a border? - $has_border = $simple_bg_color; - - // The amount of pixels inbetween characters. - $character_spacing = 1; - - // What color is the background - generally white unless we're on "hard". - if ($simple_bg_color) { - $background_color = [255, 255, 255]; - } else { - $background_color = Theme::$current->settings['verification_background'] ?? [236, 237, 243]; - } - - // The color of the characters shown (red, green, blue). - if ($simple_fg_color) { - $foreground_color = [0, 0, 0]; - } else { - $foreground_color = [64, 101, 136]; - - // Has the theme author requested a custom color? - if (isset(Theme::$current->settings['verification_foreground'])) { - $foreground_color = Theme::$current->settings['verification_foreground']; - } - } - - if (!is_dir(Theme::$current->settings['default_theme_dir'] . '/fonts')) { - return false; - } - - // Get a list of the available fonts. - $font_list = []; - $ttfont_list = []; - $endian = unpack('v', pack('S', 0x00FF)) === 0x00FF; - - $font_dir = dir(Theme::$current->settings['default_theme_dir'] . '/fonts'); - - while ($entry = $font_dir->read()) { - if (preg_match('~^(.+)\.gdf$~', $entry, $matches) === 1) { - if ($endian ^ (!str_contains($entry, '_end.gdf'))) { - $font_list[] = $entry; - } - } elseif (preg_match('~^(.+)\.ttf$~', $entry, $matches) === 1) { - $ttfont_list[] = $entry; - } - } - - if (empty($font_list)) { - return false; - } - - // For non-hard things don't even change fonts. - if (!$vary_fonts) { - $font_list = [$font_list[0]]; - - if (\in_array('AnonymousPro.ttf', $ttfont_list)) { - $ttfont_list = ['AnonymousPro.ttf']; - } else { - $ttfont_list = empty($ttfont_list) ? [] : [$ttfont_list[0]]; - } - } - - // Create a list of characters to be shown. - $characters = []; - $loaded_fonts = []; - - for ($i = 0; $i < \strlen($code); $i++) { - $characters[$i] = [ - 'id' => $code[$i], - 'font' => array_rand($font_list), - ]; - - $loaded_fonts[$characters[$i]['font']] = null; - } - - // Load all fonts and determine the maximum font height. - foreach ($loaded_fonts as $font_index => $dummy) { - $loaded_fonts[$font_index] = imageloadfont(Theme::$current->settings['default_theme_dir'] . '/fonts/' . $font_list[$font_index]); - } - - // Determine the dimensions of each character. - $extra = $image_type == 4 || $image_type == 5 ? 80 : 45; - - $total_width = $character_spacing * \strlen($code) + $extra; - $max_height = 0; - - foreach ($characters as $char_index => $character) { - $characters[$char_index]['width'] = imagefontwidth($loaded_fonts[$character['font']]); - $characters[$char_index]['height'] = imagefontheight($loaded_fonts[$character['font']]); - - $max_height = (int) max($characters[$char_index]['height'] + 5, $max_height); - $total_width += $characters[$char_index]['width']; - } - - // Create an image. - $code_image = imagecreatetruecolor($total_width, $max_height); - - // Draw the background. - $bg_color = imagecolorallocate( - $code_image, - $background_color[0], - $background_color[1], - $background_color[2], - ); - - imagefilledrectangle( - $code_image, - 0, - 0, - $total_width - 1, - $max_height - 1, - $bg_color, - ); - - // Randomize the foreground color a little. - for ($i = 0; $i < 3; $i++) { - $foreground_color[$i] = random_int(max($foreground_color[$i] - 3, 0), min($foreground_color[$i] + 3, 255)); - } - - $fg_color = imagecolorallocate( - $code_image, - $foreground_color[0], - $foreground_color[1], - $foreground_color[2], - ); - - // Color for the dots. - for ($i = 0; $i < 3; $i++) { - if ($background_color[$i] < $foreground_color[$i]) { - $dotbgcolor[$i] = random_int(0, max($foreground_color[$i] - 20, 0)); - } else { - $dotbgcolor[$i] = random_int(min($foreground_color[$i] + 20, 255), 255); - } - } - - $randomness_color = imagecolorallocate( - $code_image, - $dotbgcolor[0], - $dotbgcolor[1], - $dotbgcolor[2], - ); - - // Some squares/rectangles for new extreme level - if ($noise_type == 'extreme') { - for ($i = 0; $i < random_int(1, 5); $i++) { - $x1 = random_int(0, $total_width / 4); - $x2 = $x1 + (int) round(rand($total_width / 4, $total_width)); - $y1 = random_int(0, $max_height); - $y2 = $y1 + (int) round(rand(0, $max_height / 3)); - - imagefilledrectangle( - $code_image, - $x1, - $y1, - $x2, - $y2, - random_int(0, 1) ? $fg_color : $randomness_color, - ); - } - } - - // Fill in the characters. - if (!$disable_chars) { - $cur_x = 0; - - foreach ($characters as $char_index => $character) { - // Can we use true type fonts? - $can_do_ttf = \function_exists('imagettftext'); - - // How much rotation will we give? - if ($rotation_type == 'none') { - $angle = 0; - } else { - $angle = random_int(-100, 100) / ($rotation_type == 'high' ? 6 : 10); - } - - // What color shall we do it? - if ($font_color_type == 'cyclic') { - // Here we'll pick from a set of acceptance types. - $colors = [ - [10, 120, 95], - [46, 81, 29], - [4, 22, 154], - [131, 9, 130], - [0, 0, 0], - [143, 39, 31], - ]; - - if (!isset($last_index)) { - $last_index = -1; - } - - $new_index = $last_index; - - while ($last_index == $new_index) { - $new_index = random_int(0, \count($colors) - 1); - } - - $char_fg_color = $colors[$new_index]; - $last_index = $new_index; - } elseif ($font_color_type == 'random') { - $char_fg_color = [ - random_int(max($foreground_color[0] - 2, 0), $foreground_color[0]), - random_int(max($foreground_color[1] - 2, 0), $foreground_color[1]), - random_int(max($foreground_color[2] - 2, 0), $foreground_color[2]), - ]; - } else { - $char_fg_color = [ - $foreground_color[0], - $foreground_color[1], - $foreground_color[2], - ]; - } - - if (!empty($can_do_ttf)) { - $font_size = $font_size_random ? random_int(17, 19) : 18; - - // Work out the sizes - also fix the character width cause TTF not quite so wide! - $font_x = $font_horz_space == 'minus' && $cur_x > 0 ? $cur_x - 3 : $cur_x + 5; - $font_y = $max_height - ($font_vert_pos == 'vrandom' ? random_int(2, 8) : ($font_vert_pos == 'random' ? random_int(3, 5) : 5)); - - // What font face? - if (!empty($ttfont_list)) { - $fontface = Theme::$current->settings['default_theme_dir'] . '/fonts/' . $ttfont_list[random_int(0, \count($ttfont_list) - 1)]; - } - - // What color are we to do it in? - $is_reverse = $show_reverse_chars ? random_int(0, 1) : false; - - if (\function_exists('imagecolorallocatealpha') && $font_transparent) { - $char_color = imagecolorallocatealpha( - $code_image, - $char_fg_color[0], - $char_fg_color[1], - $char_fg_color[2], - 50, - ); - } else { - $char_color = imagecolorallocate( - $code_image, - $char_fg_color[0], - $char_fg_color[1], - $char_fg_color[2], - ); - } - - $fontcord = @imagettftext( - $code_image, - $font_size, - $angle, - $font_x, - $font_y, - $char_color, - $fontface, - $character['id'], - ); - - if (empty($fontcord)) { - $can_do_ttf = false; - } elseif ($is_reverse) { - imagefilledpolygon($code_image, $fontcord, $fg_color); - - // Put the character back! - imagettftext( - $code_image, - $font_size, - $angle, - $font_x, - $font_y, - $randomness_color, - $fontface, - $character['id'], - ); - } - - if ($can_do_ttf) { - $cur_x = max($fontcord[2], $fontcord[4]) + ($angle == 0 ? 0 : 3); - } - } - - if (!$can_do_ttf) { - // Rotating the characters a little... - if (\function_exists('imagerotate')) { - $char_image = imagecreatetruecolor( - $character['width'], - $character['height'], - ); - - $char_bgcolor = imagecolorallocate( - $char_image, - $background_color[0], - $background_color[1], - $background_color[2], - ); - - imagefilledrectangle( - $char_image, - 0, - 0, - (int) $character['width'] - 1, - (int) $character['height'] - 1, - $char_bgcolor, - ); - - imagechar( - $char_image, - $loaded_fonts[$character['font']], - 0, - 0, - $character['id'], - imagecolorallocate( - $char_image, - $char_fg_color[0], - $char_fg_color[1], - $char_fg_color[2], - ), - ); - - $rotated_char = imagerotate( - $char_image, - random_int(-100, 100) / 10, - $char_bgcolor, - ); - - imagecopy( - $code_image, - $rotated_char, - $cur_x, - 0, - 0, - 0, - $character['width'], - $character['height'], - ); - } - - // Sorry, no rotation available. - else { - imagechar( - $code_image, - $loaded_fonts[$character['font']], - $cur_x, - (int) floor(($max_height - $character['height']) / 2), - $character['id'], - imagecolorallocate( - $code_image, - $char_fg_color[0], - $char_fg_color[1], - $char_fg_color[2], - ), - ); - } - - $cur_x += $character['width'] + $character_spacing; - } - } - } - // If disabled just show a cross. - else { - imageline($code_image, 0, 0, $total_width, $max_height, $fg_color); - imageline($code_image, 0, $max_height, $total_width, 0, $fg_color); - } - - // Make the background color transparent on the hard image. - if (!$simple_bg_color) { - imagecolortransparent($code_image, $bg_color); - } - - if ($has_border) { - imagerectangle($code_image, 0, 0, $total_width - 1, $max_height - 1, $fg_color); - } - - // Add some noise to the background? - if ($noise_type != 'none') { - for ($i = random_int(0, 2); $i < $max_height; $i += random_int(1, 2)) { - for ($j = random_int(0, 10); $j < $total_width; $j += random_int(1, 10)) { - imagesetpixel($code_image, $j, $i, random_int(0, 1) ? $fg_color : $randomness_color); - } - } - - // Put in some lines too? - if ($noise_type != 'extreme') { - $num_lines = $noise_type == 'high' ? random_int(3, 7) : random_int(2, 5); - - for ($i = 0; $i < $num_lines; $i++) { - if (random_int(0, 1)) { - $x1 = random_int(0, $total_width); - $x2 = random_int(0, $total_width); - $y1 = 0; - $y2 = $max_height; - } else { - $y1 = random_int(0, $max_height); - $y2 = random_int(0, $max_height); - $x1 = 0; - $x2 = $total_width; - } - - imagesetthickness($code_image, random_int(1, 2)); - - imageline( - $code_image, - $x1, - $y1, - $x2, - $y2, - random_int(0, 1) ? $fg_color : $randomness_color, - ); - } - } else { - // Put in some ellipse - $num_ellipse = $noise_type == 'extreme' ? random_int(6, 12) : random_int(2, 6); - - for ($i = 0; $i < $num_ellipse; $i++) { - $x1 = (int) round(rand(($total_width / 4) * -1, $total_width + ($total_width / 4))); - $x2 = (int) round(rand($total_width / 2, 2 * $total_width)); - $y1 = (int) round(rand(($max_height / 4) * -1, $max_height + ($max_height / 4))); - $y2 = (int) round(rand($max_height / 2, 2 * $max_height)); - - imageellipse( - $code_image, - $x1, - $y1, - $x2, - $y2, - random_int(0, 1) ? $fg_color : $randomness_color, - ); - } - } - } - - // Show the image. - if (\function_exists('imagegif')) { - header('content-type: image/gif'); - imagegif($code_image); - } else { - header('content-type: image/png'); - imagepng($code_image); - } - - // Bail out. - die(); - } - - /** - * Show a letter for the visual verification code. - * - * Alternative function for showCodeImage() in case GD is missing. - * Includes an image from a random sub directory of default_theme_dir/fonts. - * - * @param string $letter A letter to show as an image - * @return bool False if something went wrong. Otherwise, dies. - */ - protected function showLetterImage(string $letter): bool - { - if (!is_dir(Theme::$current->settings['default_theme_dir'] . '/fonts')) { - return false; - } - - // Get a list of the available font directories. - $font_dir = dir(Theme::$current->settings['default_theme_dir'] . '/fonts'); - $font_list = []; - - while ($entry = $font_dir->read()) { - if ( - $entry[0] !== '.' - && is_dir(Theme::$current->settings['default_theme_dir'] . '/fonts/' . $entry) - && file_exists(Theme::$current->settings['default_theme_dir'] . '/fonts/' . $entry . '.gdf') - ) { - $font_list[] = $entry; - } - } - - if (empty($font_list)) { - return false; - } - - // Pick a random font. - $random_font = $font_list[array_rand($font_list)]; - - // Check if the given letter exists. - if (!file_exists(Theme::$current->settings['default_theme_dir'] . '/fonts/' . $random_font . '/' . strtoupper($letter) . '.png')) { - return false; - } - - // Include it! - header('content-type: image/png'); - - include Theme::$current->settings['default_theme_dir'] . '/fonts/' . $random_font . '/' . strtoupper($letter) . '.png'; - - // Nothing more to come. - die(); - } - - /** - * Creates a wave file that spells the letters of $word. - * Tries the user's language first, and defaults to english. - * Used by VerificationCode() (Register.php). - * - * @param string $word - * @return bool false on failure - */ - protected function createWaveFile(string $word): bool - { - // Allow max 2 requests per 20 seconds. - if (($ip = CacheApi::get('wave_file/' . User::$me->ip, 20)) > 2 || ($ip2 = CacheApi::get('wave_file/' . User::$me->ip2, 20)) > 2) { - Utils::sendHttpStatus(400); - - die(); - } - - CacheApi::put('wave_file/' . User::$me->ip, $ip ? $ip + 1 : 1, 20); - CacheApi::put('wave_file/' . User::$me->ip2, $ip2 ? $ip2 + 1 : 1, 20); - - // Fixate randomization for this word. - $tmp = unpack('n', md5($word . session_id())); - mt_srand(end($tmp)); - - // Try to see if there's a sound font in the user's language. - if (file_exists(Theme::$current->settings['default_theme_dir'] . '/fonts/sound/a.' . User::$me->language . '.wav')) { - $sound_language = User::$me->language; - } - // English should be there. - elseif (file_exists(Theme::$current->settings['default_theme_dir'] . '/fonts/sound/a.english.wav')) { - $sound_language = 'english'; - } - // Guess not... - else { - return false; - } - - // File names are in lower case so lets make sure that we are only using a lower case string - $word = Utils::strtolower($word); - - $chars = preg_split('/(.)/su', $word, 0, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); - - // Loop through all letters of the word $word. - $sound_word = ''; - - for ($i = 0; $i < \count($chars); $i++) { - $sound_letter = implode('', file(Theme::$current->settings['default_theme_dir'] . '/fonts/sound/' . $chars[$i] . '.' . $sound_language . '.wav')); - - if (!str_contains($sound_letter, 'data')) { - return false; - } - - $sound_letter = substr($sound_letter, strpos($sound_letter, 'data') + 8); - - switch ($chars[$i] === 's' ? 0 : mt_rand(0, 2)) { - case 0: - for ($j = 0, $n = \strlen($sound_letter); $j < $n; $j++) { - for ($k = 0, $m = round(mt_rand(15, 25) / 10); $k < $m; $k++) { - $sound_word .= $chars[$i] === 's' ? $sound_letter[$j] : \chr(mt_rand(max(\ord($sound_letter[$j]) - 1, 0x00), min(\ord($sound_letter[$j]) + 1, 0xFF))); - } - } - break; - - case 1: - for ($j = 0, $n = \strlen($sound_letter) - 1; $j < $n; $j += 2) { - $sound_word .= (mt_rand(0, 3) == 0 ? '' : $sound_letter[$j]) . (mt_rand(0, 3) === 0 ? $sound_letter[$j + 1] : $sound_letter[$j]) . (mt_rand(0, 3) === 0 ? $sound_letter[$j] : $sound_letter[$j + 1]) . $sound_letter[$j + 1] . (mt_rand(0, 3) == 0 ? $sound_letter[$j + 1] : ''); - } - $sound_word .= str_repeat($sound_letter[$n], 2); - break; - - case 2: - $shift = 0; - - for ($j = 0, $n = \strlen($sound_letter); $j < $n; $j++) { - if (mt_rand(0, 10) === 0) { - $shift += mt_rand(-3, 3); - } - - for ($k = 0, $m = round(mt_rand(15, 25) / 10); $k < $m; $k++) { - $sound_word .= \chr(min(max(\ord($sound_letter[$j]) + $shift, 0x00), 0xFF)); - } - } - break; - } - - $sound_word .= str_repeat(\chr(0x80), mt_rand(10000, 10500)); - } - - $data_size = \strlen($sound_word); - $file_size = $data_size + 0x24; - $content_length = $file_size + 0x08; - $sample_rate = 16000; - - // Disable compression. - ob_end_clean(); - header_remove('content-encoding'); - header('content-encoding: none'); - header('accept-ranges: bytes'); - header('connection: close'); - header('cache-control: no-cache'); - - // Output the wav. - header('content-type: audio/x-wav'); - header('expires: ' . gmdate('D, d M Y H:i:s', time() + 525600 * 60) . ' GMT'); - - if (isset($_SERVER['HTTP_RANGE'])) { - list($a, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2); - list($range) = explode(',', $range, 2); - list($range, $range_end) = explode('-', $range); - $range = \intval($range); - $range_end = !$range_end ? $content_length - 1 : \intval($range_end); - $new_length = $range_end - $range + 1; - - Utils::sendHttpStatus(206); - header("content-length: {$new_length}"); - header("content-range: bytes {$range}-{$range_end}/{$content_length}"); - } else { - header('content-length: ' . $content_length); - } - - echo pack('nnVnnnnnnnnVVnnnnV', 0x5249, 0x4646, $file_size, 0x5741, 0x5645, 0x666D, 0x7420, 0x1000, 0x0000, 0x0100, 0x0100, $sample_rate, $sample_rate, 0x0100, 0x0800, 0x6461, 0x7461, $data_size), $sound_word; - - // Nothing more to add. - die(); + $this->form_id = $_GET['vid'] ?? ''; + $this->agent_id = $_GET['aid'] ?? Uuid::create(); } } diff --git a/Sources/AntiSpam/APIs/BlankField.php b/Sources/AntiSpam/APIs/BlankField.php new file mode 100644 index 0000000000..2a926473e5 --- /dev/null +++ b/Sources/AntiSpam/APIs/BlankField.php @@ -0,0 +1,117 @@ +sessionID()]['empty_field'])) { + $this->setup(); + } + + Theme::addInlineCss('.vv_special { display: none; }'); + + return true; + } + + public function html(): void + { + echo ' +
+ ', Lang::getTxt('visual_verification_hidden', file: 'General'), ' + +
'; + } + + /** + * + */ + public function validate(?array $options = []): array|bool + { + // Hmm, it's requested but not actually declared. This shouldn't happen. + if (empty($_SESSION[$this->sessionID()]['empty_field'])) { + ErrorHandler::fatalLang('no_access', false); + } + + // While we're here, did the user do something bad? + if (!empty($_REQUEST[$_SESSION[$this->sessionID()]['empty_field']])) { + return ['wrong_verification_answer']; + } + + return true; + } + + public function refresh(bool $only_if_necessary, bool $do_test): bool + { + if (parent::shouldRefresh($only_if_necessary, $do_test)) { + $this->setup(); + } + + return false; + } + + /****************** + * Internal methods + ******************/ + + private function setup(): void + { + // We're building a field that lives in the template, that we hope to be empty later. But at least we give it a believable name. + $terms = ['gadget', 'device', 'uid', 'gid', 'guid', 'uuid', 'unique', 'identifier']; + + $second_terms = ['hash', 'cipher', 'code', 'key', 'unlock', 'bit', 'value']; + + $start = random_int(0, 27); + + $hash = bin2hex(random_bytes(2)); + + $_SESSION[$this->sessionID()]['empty_field'] = $terms[array_rand($terms)] . '-' . $second_terms[array_rand($second_terms)] . '-' . $hash; + } +} diff --git a/Sources/AntiSpam/APIs/ImageVerfication.php b/Sources/AntiSpam/APIs/ImageVerfication.php new file mode 100644 index 0000000000..a6f3897729 --- /dev/null +++ b/Sources/AntiSpam/APIs/ImageVerfication.php @@ -0,0 +1,985 @@ +show_visual !== false || (User::$me->is_admin && isset($_GET['agent'])); + } + + /** + * + */ + public function create(?array $options = []): bool + { + // Some javascript ma'am? + if ($this->show_visual && !\in_array('smf_captcha', Utils::$context['javascript_files'])) { + Theme::loadJavaScriptFile('captcha.js', ['minimize' => true], 'smf_captcha'); + } + + Utils::$context['use_graphic_library'] = \extension_loaded('gd'); + + if (empty($this->code)) { + $this->setup(); + } + + return false; + } + + public function html(): void + { + if (Utils::$context['use_graphic_library']) { + echo ' + ', Lang::getTxt('visual_verification_description', file: 'General'), ''; + } else { + echo ' + ', Lang::getTxt('visual_verification_description', file: 'General'), ' + ', Lang::getTxt('visual_verification_description', file: 'General'), ' + ', Lang::getTxt('visual_verification_description', file: 'General'), ' + ', Lang::getTxt('visual_verification_description', file: 'General'), ' + ', Lang::getTxt('visual_verification_description', file: 'General'), ' + ', Lang::getTxt('visual_verification_description', file: 'General'), ''; + } + + echo ' +
+ ', Lang::getTxt('visual_verification_sound', file: 'General'), '/ ', Lang::getTxt('visual_verification_request_new', file: 'General'), '
+ ', Lang::getTxt('visual_verification_description', file: 'General'), ' + +
'; + } + + /** + * + */ + public function validate(?array $options = []): array|bool + { + if (empty($_REQUEST[$this->sessionID()]['code']) || empty($_SESSION[$this->sessionID()]['code']) || strtoupper($_REQUEST[$this->sessionID()]['code']) !== $_SESSION[$this->sessionID()]['code']) { + return ['wrong_verification_code']; + } + + return true; + } + + public function showCode(): void + { + $this->code ??= self::code(); + + // Show a window that will play the verification code. + if (isset($_REQUEST['sound'])) { + Theme::loadTemplate('Register'); + + Utils::$context['verification_sound_href'] = Config::$scripturl . '?action=verificationcode;rand=' . bin2hex(random_bytes(16)) . ($this->form_id ? ';vid=' . $this->form_id : '') . ($this->agent_id ? ';aid=' . $this->agent_id : '') . ';format=.wav'; + Utils::$context['sub_template'] = 'verification_sound'; + Utils::$context['template_layers'] = []; + + Utils::obExit(); + } + // If we have GD, try the nice code. + elseif (empty($_REQUEST['format'])) { + if (\extension_loaded('gd') && !$this->showCodeImage($this->code)) { + Utils::sendHttpStatus(400); + } + // Otherwise just show a pre-defined letter. + elseif (isset($_REQUEST['letter'])) { + $_REQUEST['letter'] = (int) $_REQUEST['letter']; + + if ($_REQUEST['letter'] > 0 && $_REQUEST['letter'] <= \strlen($this->code) && !$this->showLetterImage(strtolower($this->code[$_REQUEST['letter'] - 1]))) { + header('content-type: image/gif'); + + die(self::BLANK_IMAGE); + } + } + // You must be up to no good. + else { + header('content-type: image/gif'); + + die(self::BLANK_IMAGE); + } + } elseif ($_REQUEST['format'] === '.wav') { + if (!$this->createWaveFile($this->code)) { + Utils::sendHttpStatus(400); + } + } + } + + public function code(): string + { + return isset($_SESSION[$this->sessionID()], $_SESSION[$this->sessionID()]['code']) ? $_SESSION[$this->sessionID()]['code'] : ($_SESSION['visual_verification_code'] ?? ''); + } + + public function refresh(bool $only_if_necessary, bool $do_test): bool + { + $should_refresh = parent::shouldRefresh($only_if_necessary, $do_test); + + // This can also force a fresh, although unlikely. + if (($this->show_visual && empty($_SESSION[$this->form_id . self::SESSION_SUFFIX]['code']))) { + $should_refresh = true; + } + + if ($should_refresh) { + $this->setup(); + } + + return $should_refresh; + } + + public function __construct(string $form_id, Uuid $agent_id) + { + parent::__construct($form_id, $agent_id); + + $this->show_visual = !empty($options['override_visual']) || (!empty(Config::$modSettings['']) && !isset($options['override_visual'])); + $this->image_href = Config::$scripturl . '?action=verificationcode;vid=' . $this->form_id . ';aid=' . $this->agent_id . ';rand=' . bin2hex(random_bytes(16)); + $this->text_value = ''; + $this->override_range = $options['override_range'] ?? ''; + } + + /*********************** + * Public static methods + ***********************/ + + public static function getConfigVars(array &$config_vars): void + { + // Generate a sample registration image. + Utils::$context['use_graphic_library'] = \in_array('gd', get_loaded_extensions()); + + $config_vars = array_merge($config_vars, [ + // Visual verification. + ['title', 'configure_verification_means'], + ['desc', 'configure_verification_means_desc'], + 'vv' => [ + 'select', + 'visual_verification_type', + [ + Lang::getTxt('setting_image_verification_off', file: 'ManageSettings'), + Lang::getTxt('setting_image_verification_vsimple', file: 'ManageSettings'), + Lang::getTxt('setting_image_verification_simple', file: 'ManageSettings'), + Lang::getTxt('setting_image_verification_medium', file: 'ManageSettings'), + Lang::getTxt('setting_image_verification_high', file: 'ManageSettings'), + Lang::getTxt('setting_image_verification_extreme', file: 'ManageSettings'), + ], + 'subtext' => Lang::getTxt('setting_visual_verification_type_desc', file: 'ManageSettings'), + 'onchange' => Utils::$context['use_graphic_library'] ? 'refreshImages();' : '', + ], + ]); + + // Generate a sample registration image. + Utils::$context['verification_image_href'] = Config::$scripturl . '?action=verificationcode;rand=' . bin2hex(random_bytes(16)) . ';agent=ImageVerfication'; + + $character_range = array_merge(range('A', 'H'), ['K', 'M', 'N', 'P', 'R'], range('T', 'Y')); + $_SESSION['visual_verification_code'] = ''; + + for ($i = 0; $i < 6; $i++) { + $_SESSION['visual_verification_code'] .= $character_range[array_rand($character_range)]; + } + + // Some javascript for CAPTCHA. + Utils::$context['settings_post_javascript'] = ''; + + if (Utils::$context['use_graphic_library']) { + Utils::$context['settings_post_javascript'] .= ' + function refreshImages() + { + var imageType = document.getElementById(\'visual_verification_type\').value; + document.getElementById(\'verification_image\').src = \'' . Utils::$context['verification_image_href'] . ';type=\' + imageType; + }'; + } + + // Show the image itself, or text saying we can't. + if (Utils::$context['use_graphic_library']) { + $config_vars['vv']['postinput'] = '
' . Lang::getTxt('setting_image_verification_sample', file: 'ManageSettings') . '
'; + } else { + $config_vars['vv']['postinput'] = '
' . Lang::getTxt('setting_image_verification_nogd', file: 'ManageSettings') . ''; + } + + } + + /****************** + * Internal methods + ******************/ + + /** + * Show an image containing the visual verification code for registration. + * + * Requires the GD extension. + * Uses a random font for each letter from default_theme_dir/fonts. + * Outputs a gif or a png (depending on whether gif ix supported). + * + * @param string $code The code to display + * @return bool False if something goes wrong. Otherwise, dies. + */ + protected function showCodeImage(string $code): bool + { + if (!\extension_loaded('gd')) { + return false; + } + + // Note: The higher the value of visual_verification_type the harder the verification is - from 0 as disabled through to 4 as "Very hard". + + // What type are we going to be doing? + $image_type = Config::$modSettings['visual_verification_type']; + + // Special case to allow the admin center to show samples. + if (User::$me->is_admin && isset($_GET['type'])) { + $image_type = (int) $_GET['type']; + } + + // Some quick references for what we do. + // Do we show no, low or high noise? + $noise_type = $image_type == 3 ? 'low' : ($image_type == 4 ? 'high' : ($image_type == 5 ? 'extreme' : 'none')); + + // Can we have more than one font in use? + $vary_fonts = $image_type > 3 ? true : false; + + // Just a plain white background? + $simple_bg_color = $image_type < 3 ? true : false; + + // Plain black foreground? + $simple_fg_color = $image_type == 0 ? true : false; + + // High much to rotate each character. + $rotation_type = $image_type == 1 ? 'none' : ($image_type > 3 ? 'low' : 'high'); + + // Do we show some characters inversed? + $show_reverse_chars = $image_type > 3 ? true : false; + + // Special case for not showing any characters. + $disable_chars = $image_type == 0 ? true : false; + + // What do we do with the font colors. Are they one color, close to one color or random? + $font_color_type = $image_type == 1 ? 'plain' : ($image_type > 3 ? 'random' : 'cyclic'); + + // Are the fonts random sizes? + $font_size_random = $image_type > 3 ? true : false; + + // How much space between characters? + $font_horz_space = $image_type > 3 ? 'high' : ($image_type == 1 ? 'medium' : 'minus'); + + // Where do characters sit on the image? (Fixed position or random/very random) + $font_vert_pos = $image_type == 1 ? 'fixed' : ($image_type > 3 ? 'vrandom' : 'random'); + + // Make font semi-transparent? + $font_transparent = $image_type == 2 || $image_type == 3 ? true : false; + + // Give the image a border? + $has_border = $simple_bg_color; + + // The amount of pixels inbetween characters. + $character_spacing = 1; + + // What color is the background - generally white unless we're on "hard". + if ($simple_bg_color) { + $background_color = [255, 255, 255]; + } else { + $background_color = Theme::$current->settings['verification_background'] ?? [236, 237, 243]; + } + + // The color of the characters shown (red, green, blue). + if ($simple_fg_color) { + $foreground_color = [0, 0, 0]; + } else { + $foreground_color = [64, 101, 136]; + + // Has the theme author requested a custom color? + if (isset(Theme::$current->settings['verification_foreground'])) { + $foreground_color = Theme::$current->settings['verification_foreground']; + } + } + + if (!is_dir(Theme::$current->settings['default_theme_dir'] . '/fonts')) { + return false; + } + + // Get a list of the available fonts. + $font_list = []; + $ttfont_list = []; + $endian = unpack('v', pack('S', 0x00FF)) === 0x00FF; + + $font_dir = dir(Theme::$current->settings['default_theme_dir'] . '/fonts'); + + while ($entry = $font_dir->read()) { + if (preg_match('~^(.+)\.gdf$~', $entry, $matches) === 1) { + if ($endian ^ (!str_contains($entry, '_end.gdf'))) { + $font_list[] = $entry; + } + } elseif (preg_match('~^(.+)\.ttf$~', $entry, $matches) === 1) { + $ttfont_list[] = $entry; + } + } + + if (empty($font_list)) { + return false; + } + + // For non-hard things don't even change fonts. + if (!$vary_fonts) { + $font_list = [$font_list[0]]; + + if (\in_array('AnonymousPro.ttf', $ttfont_list)) { + $ttfont_list = ['AnonymousPro.ttf']; + } else { + $ttfont_list = empty($ttfont_list) ? [] : [$ttfont_list[0]]; + } + } + + // Create a list of characters to be shown. + $characters = []; + $loaded_fonts = []; + + for ($i = 0; $i < \strlen($code); $i++) { + $characters[$i] = [ + 'id' => $code[$i], + 'font' => array_rand($font_list), + ]; + + $loaded_fonts[$characters[$i]['font']] = null; + } + + // Load all fonts and determine the maximum font height. + foreach ($loaded_fonts as $font_index => $dummy) { + $loaded_fonts[$font_index] = imageloadfont(Theme::$current->settings['default_theme_dir'] . '/fonts/' . $font_list[$font_index]); + } + + // Determine the dimensions of each character. + $extra = $image_type == 4 || $image_type == 5 ? 80 : 45; + + $total_width = $character_spacing * \strlen($code) + $extra; + $max_height = 0; + + foreach ($characters as $char_index => $character) { + $characters[$char_index]['width'] = imagefontwidth($loaded_fonts[$character['font']]); + $characters[$char_index]['height'] = imagefontheight($loaded_fonts[$character['font']]); + + $max_height = (int) max($characters[$char_index]['height'] + 5, $max_height); + $total_width += $characters[$char_index]['width']; + } + + // Create an image. + $code_image = imagecreatetruecolor($total_width, $max_height); + + // Draw the background. + $bg_color = imagecolorallocate( + $code_image, + $background_color[0], + $background_color[1], + $background_color[2], + ); + + imagefilledrectangle( + $code_image, + 0, + 0, + $total_width - 1, + $max_height - 1, + $bg_color, + ); + + // Randomize the foreground color a little. + for ($i = 0; $i < 3; $i++) { + $foreground_color[$i] = random_int(max($foreground_color[$i] - 3, 0), min($foreground_color[$i] + 3, 255)); + } + + $fg_color = imagecolorallocate( + $code_image, + $foreground_color[0], + $foreground_color[1], + $foreground_color[2], + ); + + // Color for the dots. + for ($i = 0; $i < 3; $i++) { + if ($background_color[$i] < $foreground_color[$i]) { + $dotbgcolor[$i] = random_int(0, max($foreground_color[$i] - 20, 0)); + } else { + $dotbgcolor[$i] = random_int(min($foreground_color[$i] + 20, 255), 255); + } + } + + $randomness_color = imagecolorallocate( + $code_image, + $dotbgcolor[0], + $dotbgcolor[1], + $dotbgcolor[2], + ); + + // Some squares/rectangles for new extreme level + if ($noise_type == 'extreme') { + for ($i = 0; $i < random_int(1, 5); $i++) { + $x1 = random_int(0, $total_width / 4); + $x2 = $x1 + (int) round(rand($total_width / 4, $total_width)); + $y1 = random_int(0, $max_height); + $y2 = $y1 + (int) round(rand(0, $max_height / 3)); + + imagefilledrectangle( + $code_image, + $x1, + $y1, + $x2, + $y2, + random_int(0, 1) ? $fg_color : $randomness_color, + ); + } + } + + // Fill in the characters. + if (!$disable_chars) { + $cur_x = 0; + + foreach ($characters as $char_index => $character) { + // Can we use true type fonts? + $can_do_ttf = \function_exists('imagettftext'); + + // How much rotation will we give? + if ($rotation_type == 'none') { + $angle = 0; + } else { + $angle = random_int(-100, 100) / ($rotation_type == 'high' ? 6 : 10); + } + + // What color shall we do it? + if ($font_color_type == 'cyclic') { + // Here we'll pick from a set of acceptance types. + $colors = [ + [10, 120, 95], + [46, 81, 29], + [4, 22, 154], + [131, 9, 130], + [0, 0, 0], + [143, 39, 31], + ]; + + if (!isset($last_index)) { + $last_index = -1; + } + + $new_index = $last_index; + + while ($last_index == $new_index) { + $new_index = random_int(0, \count($colors) - 1); + } + + $char_fg_color = $colors[$new_index]; + $last_index = $new_index; + } elseif ($font_color_type == 'random') { + $char_fg_color = [ + random_int(max($foreground_color[0] - 2, 0), $foreground_color[0]), + random_int(max($foreground_color[1] - 2, 0), $foreground_color[1]), + random_int(max($foreground_color[2] - 2, 0), $foreground_color[2]), + ]; + } else { + $char_fg_color = [ + $foreground_color[0], + $foreground_color[1], + $foreground_color[2], + ]; + } + + if (!empty($can_do_ttf)) { + $font_size = $font_size_random ? random_int(17, 19) : 18; + + // Work out the sizes - also fix the character width cause TTF not quite so wide! + $font_x = $font_horz_space == 'minus' && $cur_x > 0 ? $cur_x - 3 : $cur_x + 5; + $font_y = $max_height - ($font_vert_pos == 'vrandom' ? random_int(2, 8) : ($font_vert_pos == 'random' ? random_int(3, 5) : 5)); + + // What font face? + if (!empty($ttfont_list)) { + $fontface = Theme::$current->settings['default_theme_dir'] . '/fonts/' . $ttfont_list[random_int(0, \count($ttfont_list) - 1)]; + } + + // What color are we to do it in? + $is_reverse = $show_reverse_chars ? random_int(0, 1) : false; + + if (\function_exists('imagecolorallocatealpha') && $font_transparent) { + $char_color = imagecolorallocatealpha( + $code_image, + $char_fg_color[0], + $char_fg_color[1], + $char_fg_color[2], + 50, + ); + } else { + $char_color = imagecolorallocate( + $code_image, + $char_fg_color[0], + $char_fg_color[1], + $char_fg_color[2], + ); + } + + $fontcord = @imagettftext( + $code_image, + $font_size, + $angle, + $font_x, + $font_y, + $char_color, + $fontface, + $character['id'], + ); + + if (empty($fontcord)) { + $can_do_ttf = false; + } elseif ($is_reverse) { + imagefilledpolygon($code_image, $fontcord, $fg_color); + + // Put the character back! + imagettftext( + $code_image, + $font_size, + $angle, + $font_x, + $font_y, + $randomness_color, + $fontface, + $character['id'], + ); + } + + if ($can_do_ttf) { + $cur_x = max($fontcord[2], $fontcord[4]) + ($angle == 0 ? 0 : 3); + } + } + + if (!$can_do_ttf) { + // Rotating the characters a little... + if (\function_exists('imagerotate')) { + $char_image = imagecreatetruecolor( + $character['width'], + $character['height'], + ); + + $char_bgcolor = imagecolorallocate( + $char_image, + $background_color[0], + $background_color[1], + $background_color[2], + ); + + imagefilledrectangle( + $char_image, + 0, + 0, + (int) $character['width'] - 1, + (int) $character['height'] - 1, + $char_bgcolor, + ); + + imagechar( + $char_image, + $loaded_fonts[$character['font']], + 0, + 0, + $character['id'], + imagecolorallocate( + $char_image, + $char_fg_color[0], + $char_fg_color[1], + $char_fg_color[2], + ), + ); + + $rotated_char = imagerotate( + $char_image, + random_int(-100, 100) / 10, + $char_bgcolor, + ); + + imagecopy( + $code_image, + $rotated_char, + $cur_x, + 0, + 0, + 0, + $character['width'], + $character['height'], + ); + } + + // Sorry, no rotation available. + else { + imagechar( + $code_image, + $loaded_fonts[$character['font']], + $cur_x, + (int) floor(($max_height - $character['height']) / 2), + $character['id'], + imagecolorallocate( + $code_image, + $char_fg_color[0], + $char_fg_color[1], + $char_fg_color[2], + ), + ); + } + + $cur_x += $character['width'] + $character_spacing; + } + } + } + // If disabled just show a cross. + else { + imageline($code_image, 0, 0, $total_width, $max_height, $fg_color); + imageline($code_image, 0, $max_height, $total_width, 0, $fg_color); + } + + // Make the background color transparent on the hard image. + if (!$simple_bg_color) { + imagecolortransparent($code_image, $bg_color); + } + + if ($has_border) { + imagerectangle($code_image, 0, 0, $total_width - 1, $max_height - 1, $fg_color); + } + + // Add some noise to the background? + if ($noise_type != 'none') { + for ($i = random_int(0, 2); $i < $max_height; $i += random_int(1, 2)) { + for ($j = random_int(0, 10); $j < $total_width; $j += random_int(1, 10)) { + imagesetpixel($code_image, $j, $i, random_int(0, 1) ? $fg_color : $randomness_color); + } + } + + // Put in some lines too? + if ($noise_type != 'extreme') { + $num_lines = $noise_type == 'high' ? random_int(3, 7) : random_int(2, 5); + + for ($i = 0; $i < $num_lines; $i++) { + if (random_int(0, 1)) { + $x1 = random_int(0, $total_width); + $x2 = random_int(0, $total_width); + $y1 = 0; + $y2 = $max_height; + } else { + $y1 = random_int(0, $max_height); + $y2 = random_int(0, $max_height); + $x1 = 0; + $x2 = $total_width; + } + + imagesetthickness($code_image, random_int(1, 2)); + + imageline( + $code_image, + $x1, + $y1, + $x2, + $y2, + random_int(0, 1) ? $fg_color : $randomness_color, + ); + } + } else { + // Put in some ellipse + $num_ellipse = $noise_type == 'extreme' ? random_int(6, 12) : random_int(2, 6); + + for ($i = 0; $i < $num_ellipse; $i++) { + $x1 = (int) round(rand(($total_width / 4) * -1, $total_width + ($total_width / 4))); + $x2 = (int) round(rand($total_width / 2, 2 * $total_width)); + $y1 = (int) round(rand(($max_height / 4) * -1, $max_height + ($max_height / 4))); + $y2 = (int) round(rand($max_height / 2, 2 * $max_height)); + + imageellipse( + $code_image, + $x1, + $y1, + $x2, + $y2, + random_int(0, 1) ? $fg_color : $randomness_color, + ); + } + } + } + + // Show the image. + if (\function_exists('imagegif')) { + header('content-type: image/gif'); + imagegif($code_image); + } else { + header('content-type: image/png'); + imagepng($code_image); + } + + // Bail out. + die(); + } + + /** + * Show a letter for the visual verification code. + * + * Alternative function for showCodeImage() in case GD is missing. + * Includes an image from a random sub directory of default_theme_dir/fonts. + * + * @param string $letter A letter to show as an image + * @return bool False if something went wrong. Otherwise, dies. + */ + protected function showLetterImage(string $letter): bool + { + if (!is_dir(Theme::$current->settings['default_theme_dir'] . '/fonts')) { + return false; + } + + // Get a list of the available font directories. + $font_dir = dir(Theme::$current->settings['default_theme_dir'] . '/fonts'); + $font_list = []; + + while ($entry = $font_dir->read()) { + if ( + $entry[0] !== '.' + && is_dir(Theme::$current->settings['default_theme_dir'] . '/fonts/' . $entry) + && file_exists(Theme::$current->settings['default_theme_dir'] . '/fonts/' . $entry . '.gdf') + ) { + $font_list[] = $entry; + } + } + + if (empty($font_list)) { + return false; + } + + // Pick a random font. + $random_font = $font_list[array_rand($font_list)]; + + // Check if the given letter exists. + if (!file_exists(Theme::$current->settings['default_theme_dir'] . '/fonts/' . $random_font . '/' . strtoupper($letter) . '.png')) { + return false; + } + + // Include it! + header('content-type: image/png'); + + include Theme::$current->settings['default_theme_dir'] . '/fonts/' . $random_font . '/' . strtoupper($letter) . '.png'; + + // Nothing more to come. + die(); + } + + /** + * Creates a wave file that spells the letters of $word. + * Tries the user's language first, and defaults to english. + * Used by VerificationCode() (Register.php). + * + * @param string $word + * @return bool false on failure + */ + protected function createWaveFile(string $word): bool + { + // Allow max 2 requests per 20 seconds. + if (($ip = CacheApi::get('wave_file/' . User::$me->ip, 20)) > 2 || ($ip2 = CacheApi::get('wave_file/' . User::$me->ip2, 20)) > 2) { + Utils::sendHttpStatus(400); + + die(); + } + + CacheApi::put('wave_file/' . User::$me->ip, $ip ? $ip + 1 : 1, 20); + CacheApi::put('wave_file/' . User::$me->ip2, $ip2 ? $ip2 + 1 : 1, 20); + + // Fixate randomization for this word. + $tmp = unpack('n', md5($word . session_id())); + mt_srand(end($tmp)); + + // Try to see if there's a sound font in the user's language. + if (file_exists(Theme::$current->settings['default_theme_dir'] . '/fonts/sound/a.' . User::$me->language . '.wav')) { + $sound_language = User::$me->language; + } + // English should be there. + elseif (file_exists(Theme::$current->settings['default_theme_dir'] . '/fonts/sound/a.english.wav')) { + $sound_language = 'english'; + } + // Guess not... + else { + return false; + } + + // File names are in lower case so lets make sure that we are only using a lower case string + $word = Utils::strtolower($word); + + $chars = preg_split('/(.)/su', $word, 0, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + + // Loop through all letters of the word $word. + $sound_word = ''; + + for ($i = 0; $i < \count($chars); $i++) { + $sound_letter = implode('', file(Theme::$current->settings['default_theme_dir'] . '/fonts/sound/' . $chars[$i] . '.' . $sound_language . '.wav')); + + if (!str_contains($sound_letter, 'data')) { + return false; + } + + $sound_letter = substr($sound_letter, strpos($sound_letter, 'data') + 8); + + switch ($chars[$i] === 's' ? 0 : mt_rand(0, 2)) { + case 0: + for ($j = 0, $n = \strlen($sound_letter); $j < $n; $j++) { + for ($k = 0, $m = round(mt_rand(15, 25) / 10); $k < $m; $k++) { + $sound_word .= $chars[$i] === 's' ? $sound_letter[$j] : \chr(mt_rand(max(\ord($sound_letter[$j]) - 1, 0x00), min(\ord($sound_letter[$j]) + 1, 0xFF))); + } + } + break; + + case 1: + for ($j = 0, $n = \strlen($sound_letter) - 1; $j < $n; $j += 2) { + $sound_word .= (mt_rand(0, 3) == 0 ? '' : $sound_letter[$j]) . (mt_rand(0, 3) === 0 ? $sound_letter[$j + 1] : $sound_letter[$j]) . (mt_rand(0, 3) === 0 ? $sound_letter[$j] : $sound_letter[$j + 1]) . $sound_letter[$j + 1] . (mt_rand(0, 3) == 0 ? $sound_letter[$j + 1] : ''); + } + $sound_word .= str_repeat($sound_letter[$n], 2); + break; + + case 2: + $shift = 0; + + for ($j = 0, $n = \strlen($sound_letter); $j < $n; $j++) { + if (mt_rand(0, 10) === 0) { + $shift += mt_rand(-3, 3); + } + + for ($k = 0, $m = round(mt_rand(15, 25) / 10); $k < $m; $k++) { + $sound_word .= \chr(min(max(\ord($sound_letter[$j]) + $shift, 0x00), 0xFF)); + } + } + break; + } + + $sound_word .= str_repeat(\chr(0x80), mt_rand(10000, 10500)); + } + + $data_size = \strlen($sound_word); + $file_size = $data_size + 0x24; + $content_length = $file_size + 0x08; + $sample_rate = 16000; + + // Disable compression. + ob_end_clean(); + header_remove('content-encoding'); + header('content-encoding: none'); + header('accept-ranges: bytes'); + header('connection: close'); + header('cache-control: no-cache'); + + // Output the wav. + header('content-type: audio/x-wav'); + header('expires: ' . gmdate('D, d M Y H:i:s', time() + 525600 * 60) . ' GMT'); + + if (isset($_SERVER['HTTP_RANGE'])) { + list($a, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2); + list($range) = explode(',', $range, 2); + list($range, $range_end) = explode('-', $range); + $range = \intval($range); + $range_end = !$range_end ? $content_length - 1 : \intval($range_end); + $new_length = $range_end - $range + 1; + + Utils::sendHttpStatus(206); + header("content-length: {$new_length}"); + header("content-range: bytes {$range}-{$range_end}/{$content_length}"); + } else { + header('content-length: ' . $content_length); + } + + echo pack('nnVnnnnnnnnVVnnnnV', 0x5249, 0x4646, $file_size, 0x5741, 0x5645, 0x666D, 0x7420, 0x1000, 0x0000, 0x0100, 0x0100, $sample_rate, $sample_rate, 0x0100, 0x0800, 0x6461, 0x7461, $data_size), $sound_word; + + // Nothing more to add. + die(); + } + + /** + * Adds JavaScript for the visual captcha. + */ + protected function addCaptchaJavaScript(): void + { + if (!$this->show_visual) { + return; + } + + Utils::$context['insert_after_template'] .= ' + '; + } + + private function setup(): void + { + // Skip I, J, L, O, Q, S and Z. + Utils::$context['standard_captcha_range'] = array_merge(range('A', 'H'), ['K', 'M', 'N', 'P', 'R'], range('T', 'Y')); + + // Are we overriding the range? + $character_range = !empty($this->override_range) ? $this->override_range : Utils::$context['standard_captcha_range']; + + $_SESSION[$this->sessionID()]['code'] = ''; + + for ($i = 0; $i < 6; $i++) { + $_SESSION[$this->sessionID()]['code'] .= $character_range[array_rand($character_range)]; + } + + $this->text_value = !empty($_REQUEST[$this->sessionID()]['code']) ? Utils::htmlspecialchars($_REQUEST[$this->sessionID()]['code']) : ''; + } +} diff --git a/Sources/AntiSpam/APIs/VerificationQuestions.php b/Sources/AntiSpam/APIs/VerificationQuestions.php new file mode 100644 index 0000000000..08dfa5f372 --- /dev/null +++ b/Sources/AntiSpam/APIs/VerificationQuestions.php @@ -0,0 +1,506 @@ +number_questions > 0; + } + + /** + * + */ + public function create(?array $options = []): bool + { + $this->question_ids = !empty($_SESSION[$this->sessionID()]['q']) ? $_SESSION[$this->sessionID()]['q'] : []; + + // We already have some. + if (!empty($this->question_ids)) { + return true; + } + + // Attempt to try the current page's language, followed by the user's preference, followed by the site default. + $possible_langs = []; + + if (isset($_SESSION['language'])) { + $possible_langs[] = strtr($_SESSION['language'], ['-utf8' => '']); + } + + if (!empty(User::$me->language)) { + $possible_langs[] = User::$me->language; + } + + $possible_langs[] = Lang::$default; + + $this->question_ids = []; + + foreach ($possible_langs as $lang) { + $lang = strtr($lang, ['-utf8' => '']); + + if (isset(Config::$modSettings['question_id_cache']['langs'][$lang])) { + // If we find questions for this, grab the ids from this language's ones, randomize the array and take just the number we need. + $this->question_ids = Config::$modSettings['question_id_cache']['langs'][$lang]; + + shuffle($this->question_ids); + + $this->question_ids = \array_slice($this->question_ids, 0, $this->number_questions); + + break; + } + } + + $this->setQuestions(); + + return true; + } + + public function html(): void + { + // Where in the question array is this question? + foreach ($this->questions as $qIndex => $q) { + echo ' +
+ ' . $q['q'] . ':
+ +
'; + } + } + + /** + * + */ + public function validate(?array $options = []): array|bool + { + $incorrectQuestions = []; + + foreach ($_SESSION[$this->sessionID()]['q'] as $q) { + // We don't have this question any more, thus no answers. + if (!isset(Config::$modSettings['question_id_cache']['questions'][$q])) { + continue; + } + + // We have our question but it might have multiple answers. + // First, did they actually answer this question? + if (!isset($_REQUEST[$this->sessionID()]['q'][$q]) || trim($_REQUEST[$this->sessionID()]['q'][$q]) == '') { + $incorrectQuestions[] = $q; + + continue; + } + + // Second, is their answer in the list of possible answers? + $given_answer = trim(Utils::htmlspecialchars(Utils::convertCase($_REQUEST[$this->sessionID()]['q'][$q], 'fold'))); + + if (!\in_array($given_answer, Config::$modSettings['question_id_cache']['questions'][$q]['answers'])) { + $incorrectQuestions[] = $q; + } + } + + if (!empty($incorrectQuestions)) { + return ['wrong_verification_answer']; + } + + return true; + } + + public function refresh(bool $only_if_necessary, bool $do_test): bool + { + $should_refresh = parent::shouldRefresh($only_if_necessary, $do_test); + + // This can also force a fresh, although unlikely. + if ($this->number_questions > 0 && empty($_SESSION[$this->sessionID()]['q'])) { + $should_refresh = true; + } + + if ($should_refresh) { + $this->setQuestions(); + } + + return $should_refresh; + } + + public function __construct(string $form_id, Uuid $agent_id) + { + parent::__construct($form_id, $agent_id); + + $this->number_questions = $options['override_qs'] ?? (!empty(Config::$modSettings['qa_verification_number']) ? (int) Config::$modSettings['qa_verification_number'] : 0); + $this->questions = []; + + $this->loadQuestionCache(); + } + + /*********************** + * Public static methods + ***********************/ + + public static function getConfigVars(array &$config_vars): void + { + $config_vars = array_merge($config_vars, [ + // Clever Thomas, who is looking sheepy now? Not I, the mighty sword swinger did say. + ['title', 'setup_verification_questions'], + ['desc', 'setup_verification_questions_desc'], + [ + 'int', + 'qa_verification_number', + 'subtext' => Lang::getTxt('setting_qa_verification_number_desc', file: 'ManageSettings'), + ], + ['callback', 'question_answer_list'], + ]); + + // Firstly, figure out what languages we're dealing with, and do a little processing for the form's benefit. + Lang::get(); + Utils::$context['qa_languages'] = []; + + foreach (Utils::$context['languages'] as $lang_id => $lang) { + $lang_id = strtr($lang_id, ['-utf8' => '']); + $lang['name'] = strtr($lang['name'], ['-utf8' => '']); + Utils::$context['qa_languages'][$lang_id] = $lang; + } + + // Secondly, load any questions we currently have. + Utils::$context['question_answers'] = []; + + $request = Db::$db->query( + 'SELECT id_question, lngfile, question, answers + FROM {db_prefix}qanda', + ); + + while ($row = Db::$db->fetch_assoc($request)) { + $lang = strtr($row['lngfile'], ['-utf8' => '']); + + Utils::$context['question_answers'][$row['id_question']] = [ + 'lngfile' => $lang, + 'question' => $row['question'], + 'answers' => (array) Utils::jsonDecode($row['answers'], true), + ]; + + Utils::$context['qa_by_lang'][$lang][] = $row['id_question']; + } + Db::$db->free_result($request); + + if (empty(Utils::$context['qa_by_lang'][strtr(Lang::$default, ['-utf8' => ''])]) && !empty(Utils::$context['question_answers'])) { + if (empty(Utils::$context['settings_insert_above'])) { + Utils::$context['settings_insert_above'] = ''; + } + + Utils::$context['settings_insert_above'] .= '
' . Lang::getTxt('question_not_defined', Utils::$context['languages'][Lang::$default], file: 'ManageSettings') . '
'; + } + + // Thirdly, push some JavaScript for the form to make it work. + $nextrow = !empty(Utils::$context['question_answers']) ? max(array_keys(Utils::$context['question_answers'])) + 1 : 1; + $setup_verification_add_answer = Utils::escapeJavaScript(Lang::getTxt('setup_verification_add_answer', file: 'ManageSettings')); + $default_lang = strtr(Lang::$default, ['-utf8' => '']); + + Theme::addInlineJavaScript(<<
').insertBefore($(this).parent()); + nextrow++; + }); + $(".qa_fieldset ").on("click", ".qa_add_answer a", function() { + var attr = $(this).closest("dd").find(".verification_answer:last").attr("name"); + $('').insertBefore($(this).closest("div")); + return false; + }); + $("#qa_dt_{$default_lang} a").click(); + END, true); + + if (isset($_GET['save'])) { + } + } + + public static function saveConfigVars(): void + { + // Handle verification questions. + $changes = [ + 'insert' => [], + 'replace' => [], + 'delete' => [], + ]; + + $qs_per_lang = []; + + foreach (Utils::$context['qa_languages'] as $lang_id => $dummy) { + // If we had some questions for this language before, but don't now, delete everything from that language. + if ((!isset($_POST['question'][$lang_id]) || !\is_array($_POST['question'][$lang_id])) && !empty(Utils::$context['qa_by_lang'][$lang_id])) { + $changes['delete'] = array_merge($changes['delete'], Utils::$context['qa_by_lang'][$lang_id]); + } + + // Now step through and see if any existing questions no longer exist. + if (!empty(Utils::$context['qa_by_lang'][$lang_id])) { + foreach (Utils::$context['qa_by_lang'][$lang_id] as $q_id) { + if (empty($_POST['question'][$lang_id][$q_id])) { + $changes['delete'][] = $q_id; + } + } + } + + // Now let's see if there are new questions or ones that need updating. + if (isset($_POST['question'][$lang_id])) { + foreach ($_POST['question'][$lang_id] as $q_id => $question) { + // Ignore junky ids. + $q_id = (int) $q_id; + + if ($q_id <= 0) { + continue; + } + + // Check the question isn't empty (because they want to delete it?) + if (empty($question) || trim($question) == '') { + if (isset(Utils::$context['question_answers'][$q_id])) { + $changes['delete'][] = $q_id; + } + + continue; + } + + $question = Utils::htmlspecialchars(trim($question)); + + // Get the answers. Firstly check there actually might be some. + if (!isset($_POST['answer'][$lang_id][$q_id]) || !\is_array($_POST['answer'][$lang_id][$q_id])) { + if (isset(Utils::$context['question_answers'][$q_id])) { + $changes['delete'][] = $q_id; + } + + continue; + } + + // Now get them and check that they might be viable. + $answers = []; + + foreach ($_POST['answer'][$lang_id][$q_id] as $answer) { + if (!empty($answer) && trim($answer) !== '') { + $answers[] = Utils::htmlspecialchars(trim($answer)); + } + } + + if (empty($answers)) { + if (isset(Utils::$context['question_answers'][$q_id])) { + $changes['delete'][] = $q_id; + } + + continue; + } + + $answers = Utils::jsonEncode($answers); + + // At this point we know we have a question and some answers. What are we doing with it? + if (!isset(Utils::$context['question_answers'][$q_id])) { + // New question. Now, we don't want to randomly consume ids, so we'll set those, rather than trusting the browser's supplied ids. + $changes['insert'][] = [$lang_id, $question, $answers]; + } else { + // It's an existing question. Let's see what's changed, if anything. + if ($lang_id != Utils::$context['question_answers'][$q_id]['lngfile'] || $question != Utils::$context['question_answers'][$q_id]['question'] || $answers != Utils::$context['question_answers'][$q_id]['answers']) { + $changes['replace'][$q_id] = ['lngfile' => $lang_id, 'question' => $question, 'answers' => $answers]; + } + } + + if (!isset($qs_per_lang[$lang_id])) { + $qs_per_lang[$lang_id] = 0; + } + $qs_per_lang[$lang_id]++; + } + } + } + + // OK, so changes? + if (!empty($changes['delete'])) { + Db::$db->query( + 'DELETE FROM {db_prefix}qanda + WHERE id_question IN ({array_int:questions})', + [ + 'questions' => $changes['delete'], + ], + ); + } + + if (!empty($changes['replace'])) { + foreach ($changes['replace'] as $q_id => $question) { + Db::$db->query( + 'UPDATE {db_prefix}qanda + SET lngfile = {string:lngfile}, + question = {string:question}, + answers = {string:answers} + WHERE id_question = {int:id_question}', + [ + 'id_question' => $q_id, + 'lngfile' => $question['lngfile'], + 'question' => $question['question'], + 'answers' => $question['answers'], + ], + ); + } + } + + if (!empty($changes['insert'])) { + Db::$db->insert( + 'insert', + '{db_prefix}qanda', + ['lngfile' => 'string-50', 'question' => 'string-255', 'answers' => 'string-65534'], + $changes['insert'], + ['id_question'], + ); + } + + // Lastly, the count of messages needs to be no more than the lowest number of questions for any one language. + $count_questions = empty($qs_per_lang) ? 0 : min($qs_per_lang); + + if (empty($count_questions) || $_POST['qa_verification_number'] > $count_questions) { + $_POST['qa_verification_number'] = $count_questions; + } + + CacheApi::put('verificationQuestions', null, 300); + + } + + /****************** + * Internal methods + ******************/ + + /** + * Sets the verification questions and answers for this instance. + */ + protected function setQuestions(): void + { + // Have we got some questions to load? + if (empty($this->question_ids)) { + return; + } + + $_SESSION[$this->sessionID()]['q'] = []; + + foreach ($this->question_ids as $q) { + // Bit of a shortcut this. + $row = &Config::$modSettings['question_id_cache']['questions'][$q]; + + if (empty($row['question'])) { + continue; + } + + $this->questions[] = [ + 'id' => $q, + 'q' => Utils::adjustHeadingLevels(Parser::transform($row['question'], options: ['no_paragraphs' => true]), null), + 'is_error' => !empty($incorrectQuestions) && \in_array($q, $incorrectQuestions), + // Remember a previous submission? + 'a' => isset($_REQUEST[$this->sessionID()], $_REQUEST[$this->sessionID()]['q'], $_REQUEST[$this->sessionID()]['q'][$q]) ? Utils::htmlspecialchars($_REQUEST[$this->sessionID()]['q'][$q]) : '', + ]; + + $_SESSION[$this->sessionID()]['q'][] = $q; + } + } + + /** + * Loads the cache of verification questions and answers. + */ + protected function loadQuestionCache(): void + { + if (empty($this->number_questions) || !empty(Config::$modSettings['question_id_cache'])) { + return; + } + + if ((Config::$modSettings['question_id_cache'] = CacheApi::get('verificationQuestions', 300)) == null) { + Config::$modSettings['question_id_cache'] = [ + 'questions' => [], + 'langs' => [], + ]; + + $request = Db::$db->query( + 'SELECT id_question, lngfile, question, answers + FROM {db_prefix}qanda', + [], + ); + + while ($row = Db::$db->fetch_assoc($request)) { + $id_question = $row['id_question']; + + unset($row['id_question']); + + $row['answers'] = (array) Utils::jsonDecode($row['answers'], true); + + foreach ($row['answers'] as $k => $v) { + $row['answers'][$k] = Utils::convertCase($v, 'fold'); + } + + Config::$modSettings['question_id_cache']['questions'][$id_question] = $row; + Config::$modSettings['question_id_cache']['langs'][$row['lngfile']][] = $id_question; + } + Db::$db->free_result($request); + + CacheApi::put('verificationQuestions', Config::$modSettings['question_id_cache'], 300); + } + } +} diff --git a/Sources/AntiSpam/APIs/index.php b/Sources/AntiSpam/APIs/index.php new file mode 100644 index 0000000000..cc9dd08570 --- /dev/null +++ b/Sources/AntiSpam/APIs/index.php @@ -0,0 +1,8 @@ +recaptcha_site_key) && $this->can_recaptcha; + } + + /** + * + */ + public function create(?array $options = []): bool + { + // The HTML registers it. + return true; + } + + public function html(): void + { + $lang = Lang::getTxt(Lang::txtExists('lang_recaptcha', file: 'General') ? 'lang_recaptcha' : 'lang_dictionary', file: 'General'); + + Theme::loadJavaScriptFile('https://www.google.com/recaptcha/api.js?hl=' . $lang, ['external' => true]); + + echo ' +
'; + } + + /** + * + */ + public function validate(?array $options = []): array|bool + { + $reCaptcha = new \ReCaptcha\ReCaptcha(Config::$modSettings['recaptcha_secret_key'], new \ReCaptcha\RequestMethod\SocketPost()); + + // Was there a reCAPTCHA response? + if (isset($_POST['g-recaptcha-response'])) { + $resp = $reCaptcha->verify($_POST['g-recaptcha-response'], User::$me->ip); + + if (!$resp->isSuccess()) { + return ['wrong_verification_recaptcha']; + } + } else { + return ['wrong_verification_code']; + } + + return true; + } + + public function refresh(bool $only_if_necessary, bool $do_test): bool + { + return false; + } + + public function __construct(string $form_id, Uuid $agent_id) + { + parent::__construct($form_id, $agent_id); + + if (empty(Config::$modSettings['recaptcha_site_key'])) { + return; + } + + // Only allow 40 alphanumeric, underscore, and dash characters. + $this->recaptcha_site_key = substr(preg_replace('/\W/', '', Config::$modSettings['recaptcha_site_key']), 0, 40); + + // Light or dark theme... + $this->recaptcha_theme = Config::$modSettings['recaptcha_theme'] == 'dark' ? 'dark' : 'light'; + + $this->can_recaptcha = !empty(Config::$modSettings['recaptcha_enabled']) && !empty(Config::$modSettings['recaptcha_site_key']) && !empty(Config::$modSettings['recaptcha_secret_key']); + } + + /*********************** + * Public static methods + ***********************/ + + public static function getConfigVars(array &$config_vars): void + { + $config_vars = array_merge($config_vars, [ + // reCAPTCHA + ['title', 'recaptcha_configure'], + ['desc', 'recaptcha_configure_desc', 'class' => 'windowbg'], + [ + 'check', + 'recaptcha_enabled', + 'subtext' => Lang::getTxt('recaptcha_enable_desc', file: 'ManageSettings'), + ], + [ + 'text', + 'recaptcha_site_key', + 'subtext' => Lang::getTxt('recaptcha_site_key_desc', file: 'ManageSettings'), + ], + [ + 'text', + 'recaptcha_secret_key', + 'subtext' => Lang::getTxt('recaptcha_secret_key_desc', file: 'ManageSettings'), + ], + [ + 'select', + 'recaptcha_theme', + [ + 'light' => Lang::getTxt('recaptcha_theme_light', file: 'ManageSettings'), + 'dark' => Lang::getTxt('recaptcha_theme_dark', file: 'ManageSettings'), + ], + ], + ]); + } +} diff --git a/Sources/AntiSpam/AntiSpam.php b/Sources/AntiSpam/AntiSpam.php new file mode 100644 index 0000000000..88acb91304 --- /dev/null +++ b/Sources/AntiSpam/AntiSpam.php @@ -0,0 +1,369 @@ +form_id = $form_id; + $this->agent_id = $agent_id; + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Create all the agents. + */ + final public static function create(string $form_id, ?array $options = []): array + { + $_SESSION[self::session($form_id)] ??= ['agents' => []]; + + $data = []; + + foreach (self::parseConfiguredAgents() as $agent) { + $agent_id = Uuid::create(); + $_SESSION[self::session($form_id)]['agents'][(string) $agent_id] = $agent; + + /** + * @var AntiSpamInterface|AntiSpam $agent_api + */ + $agent_api = self::loadAgent($agent, $form_id, $agent_id); + + if ($agent_api === null) { + continue; + } + + // Inform the agent we are requesting some protection. + if ($agent_api->isConfigured() && $agent_api->create($options)) { + $data[$agent] = [$agent_api, 'html']; + } + } + + return $data; + } + + /** + * Validate all agents. + * + * @return array|true True if we passed, an array of errors if we failed. + */ + final public static function validate(string $form_id, ?array $options = []): array|bool + { + // Not in session, no verification will pass. + if (empty($form_id) || !isset($_SESSION[self::session($form_id)], $_SESSION[self::session($form_id)]['agents'])) { + return ['session_timeout']; + } + + $errors = []; + + foreach ($_SESSION[self::session($form_id)]['agents'] as $agent_id => $agent) { + $agent_id = Uuid::createFromString($agent_id); + + /** + * @var ?AntiSpamInterface|AntiSpamAgent $agent_api + */ + $agent_api = self::loadAgent($agent, $form_id, $agent_id); + + // Not in session, no verification will pass. + if ($agent_api === null) { + $errors[] = 'session_timeout'; + continue; + } + + // Inform the agent we are asking to validate.. + if ($agent_api->isConfigured()) { + $rt = $agent_api->validate($options); + + if ($rt !== true) { + $errors = array_merge($errors, $rt); + } + } + } + + if (empty($errors)) { + // SMF 2.1 compatbility, why do we care? + $_SESSION[self::session($form_id)]['did_pass'] = true; + + return true; + } + + $_SESSION[self::session($form_id)]['errors'] ??= 0; + $_SESSION[self::session($form_id)]['errors']++; + + // Ran out of tries, refresh things. + if (self::MAX_ERRORS < $_SESSION[self::session($form_id)]['errors']) { + self::refresh($form_id, false); + } + + + return $errors; + } + + final public static function findCode(string $form_id, Uuid|string $agent_id): string + { + // Not in session, no verification will pass. + if (empty($form_id) || !isset($_SESSION[self::session($form_id)])) { + return ''; + } + + if (!empty($form_id) && \is_string($agent_id)) { + $agent_id = Uuid::createFromString($agent_id); + } + + // Not in session, no verification will pass. + if (empty($agent_id) || !isset($_SESSION[self::session($form_id)]['agents'][$agent_id])) { + return ''; + } + + + $agent = $_SESSION[self::session($form_id)]['agents'][$agent_id]; + + /** + * @var ?AntiSpamInterface|AntiSpamAgent $agent_api + */ + $agent_api = self::loadAgent($agent, $form_id, $agent_id); + + if ($agent_api !== null && $agent_api->isConfigured()) { + return $agent_api->code(); + } + + return ''; + } + + final public static function showCode(string $form_id, Uuid|string $agent_id): void + { + // Not in session, no verification will pass. + if (empty($form_id) || !isset($_SESSION[self::session($form_id)])) { + return; + } + + if (\is_string($agent_id)) { + $agent_id = Uuid::createFromString($agent_id); + } + + // Not in session, no verification will pass. + if (empty($agent_id) || !isset($_SESSION[self::session($form_id)]['agents'][(string) $agent_id])) { + return; + } + + $agent = $_SESSION[self::session($form_id)]['agents'][(string) $agent_id]; + + /** + * @var ?AntiSpamInterface|AntiSpamAgent $agent_api + */ + $agent_api = self::loadAgent($agent, $form_id, $agent_id); + + if ($agent_api !== null && $agent_api->isConfigured()) { + $agent_api->showCode(); + } + } + + final public static function setupTestCode(string $rand, string $agent): array + { + $api_classes = new \GlobIterator(self::APIS_FOLDER . '/*.php', \FilesystemIterator::NEW_CURRENT_AND_KEY); + + $found = false; + + foreach ($api_classes as $file_info) { + if ($file_info->getBasename() !== 'index.php' && $file_info->getBasename('.php') === $agent) { + $found = true; + break; + } + } + + $form_id = 'admin'; + $agent_id = Uuid::create(); + $_SESSION[self::session($form_id)]['agents'][(string) $agent_id] = $agent; + + /** + * @var ?AntiSpamInterface|AntiSpamAgent $agent_api + */ + $agent_api = self::loadAgent($agent, $form_id, $agent_id); + + if ($agent_api !== null && $agent_api->isConfigured()) { + // Call for the code, discard it to ensure we have generated a code. + $agent_api->code(); + } + + return [$form_id, $agent_id]; + } + + /** + * Do we need to refresh this verification? + * + * @param bool $only_if_necessary If true, only refresh if we absolutely must. + * @param bool $do_test Whether we are checking their answers. + */ + final public static function refresh(string $form_id, bool $only_if_necessary = true, bool $do_test = false): void + { + $result = false; + + foreach ($_SESSION[self::session($form_id)]['agents'] as $agent_id => $agent) { + $agent_id = Uuid::createFromString($agent_id); + + /** + * @var ?AntiSpamInterface|AntiSpamAgent $agent_api + */ + $agent_api = self::loadAgent($agent, $form_id, $agent_id); + + // Not in session, no verification will pass. + if ($agentID === null) { + continue; + } + + // Inform the agent we are asking to validate. + if ($agent_api->isConfigured()) { + $result = $result || $agent_api->refresh($only_if_necessary, $do_test); + } + } + + // If we refreshed anything, reset it all. + if ($result) { + $_SESSION[self::session($form_id)]['count'] = 0; + $_SESSION[self::session($form_id)]['errors'] = 0; + $_SESSION[self::session($form_id)]['did_pass'] = false; + } + } + + final public static function getConfigVars(array &$config_vars, array &$agnets): void + { + $api_classes = new \GlobIterator(self::APIS_FOLDER . '/*.php', \FilesystemIterator::NEW_CURRENT_AND_KEY); + + foreach ($api_classes as $file_info) { + if ($file_info->getBasename() === 'index.php') { + continue; + } + $agent = $file_info->getBasename('.php'); + $fully_qualified_class_name = self::APIS_NAMESPACE . $agent; + + if (!class_exists($fully_qualified_class_name)) { + continue; + } + + /* + * @var ?AntiSpamInterface|AntiSpamAgent $fully_qualified_class_name + */ + $fully_qualified_class_name::getConfigVars(config_vars: $config_vars); + $agnets[$agent] = Lang::txtExists('antispam_agent_' . $agent) ? Lang::getTxt('antispam_agent_' . $agent) : $agent; + } + } + + final public static function saveConfigVars(): void + { + $api_classes = new \GlobIterator(self::APIS_FOLDER . '/*.php', \FilesystemIterator::NEW_CURRENT_AND_KEY); + + foreach ($api_classes as $file_info) { + if ($file_info->getBasename() === 'index.php') { + continue; + } + $agent = $file_info->getBasename('.php'); + $fully_qualified_class_name = self::APIS_NAMESPACE . $agent; + + if (!class_exists($fully_qualified_class_name)) { + continue; + } + + /* + * @var ?AntiSpamInterface|AntiSpamAgent $fully_qualified_class_name + */ + $fully_qualified_class_name::saveConfigVars(); + } + } + + /************************* + * Internal static methods + *************************/ + + protected static function session(string $id): string + { + return $id . self::SESSION_SUFFIX; + } + + private static function parseConfiguredAgents(): array + { + return Utils::jsonDecode(Config::$modSettings['antispam_agents'] ?? '') ?? []; + } + + private static function loadAgent(string $agent, $id, $agent_id): ?AntiSpamInterface + { + $fully_qualified_class_name = self::APIS_NAMESPACE . $agent; + + if (!class_exists($fully_qualified_class_name)) { + return null; + } + + /** + * @var AntiSpamInterface|AntiSpamAgent $fully_qualified_class_name + * @var AntiSpamInterface|AntiSpamAgent $agent_api + */ + $agent_api = new $fully_qualified_class_name($id, $agent_id); + + // Has to be a valid agent. + if (!($agent_api instanceof AntiSpamInterface) || !($agent_api instanceof AntiSpamAgent)) { + return null; + } + + return $agent_api; + } +} diff --git a/Sources/AntiSpam/AntiSpamAgent.php b/Sources/AntiSpam/AntiSpamAgent.php new file mode 100644 index 0000000000..4561cf4bdc --- /dev/null +++ b/Sources/AntiSpam/AntiSpamAgent.php @@ -0,0 +1,175 @@ +form_id = $form_id; + $this->agent_id = $agent_id; + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Specify custom settings that the agent supports. + * + * @param array $config_vars Additional config_vars, see ManageSettings.php for usage. + */ + public static function getConfigVars(array &$config_vars): void {} + + /** + * Specify custom settings that the agent supports. + * + * @param array $config_vars Additional config_vars, see ManageSettings.php for usage. + */ + public static function saveConfigVars(): void {} + + /****************** + * Internal methods + ******************/ + + /** + * Do we need to refresh this verification? + * + * @param bool $only_if_necessary If true, only refresh if we absolutely must. + * @param bool $do_test Whether we are checking their answers. + * @return bool Whether we should refresh this verification. + */ + protected function shouldRefresh(bool $only_if_necessary, bool $do_test): bool + { + // This means: + // 1. If we weren't asked to avoid refreshing, refresh. + // 2. If we didn't check their answers, refresh. + // 3. If they previously passed a verification in this session (which + // means they need a new one), or haven't tried yet, or tried too + // many times, refresh. + $should_refresh = !$only_if_necessary && !$do_test && (!empty($_SESSION[$this->sessionID()]['did_pass']) || empty($_SESSION[$this->sessionID()]['count']) || $_SESSION[$this->sessionID()]['count'] > self::MAX_ATTEMPTS); + + // Any errors means we refresh potentially. + if (!empty($this->errors)) { + if (empty($_SESSION[$this->sessionID()]['errors'])) { + $_SESSION[$this->sessionID()]['errors'] = 0; + } + // Too many errors? + elseif ($_SESSION[$this->sessionID()]['errors'] > $this->max_errors) { + $should_refresh = true; + } + + // Keep track of these. + $_SESSION[$this->sessionID()]['errors']++; + } + + return $should_refresh; + } + + protected function sessionID(): string + { + return (string) $this->form_id . self::SESSION_SUFFIX; + } +} diff --git a/Sources/AntiSpam/AntiSpamInterface.php b/Sources/AntiSpam/AntiSpamInterface.php new file mode 100644 index 0000000000..704c601e15 --- /dev/null +++ b/Sources/AntiSpam/AntiSpamInterface.php @@ -0,0 +1,80 @@ +id = $options['id'] ?? rtrim(($_REQUEST['action'] ?? 'post'), '2'); + + $this->init(); + + // Testing. + if ($do_test) { + $results = AntiSpam::validate($this->id, $options); + + // Do ay hooks have something to say about this verification? + IntegrationHook::call('integrate_create_control_verification_test', [$this, &$this->errors]); + + if ($results === true) { + $this->result = true; + } else { + $this->errors = $results; + + // Hooks may need to know about this. + IntegrationHook::call('integrate_create_control_verification_refresh', [$this]); + } + } + + // Let our hooks know that we are done with the verification process. + IntegrationHook::call('integrate_create_control_verification_post', [&$this->errors, $do_test]); + + // Return errors if we have them. + if (!empty($this->errors)) { + // Backward compatibility. + Utils::$context['require_verification'] = $this->errors; + Utils::$context['visual_verification'] = $this->result; + Utils::$context['visual_verification_id'] = $this->id; + + $this->result = true; + } else { + // Say that everything went well, chaps. + $this->result = true; + } + + // Setup a new one. + self::$loaded[$this->id] = AntiSpam::create($this->id, $options); + + if (empty($this->errors)) { + Utils::$context['require_verification'] = $this->result; + Utils::$context['visual_verification'] = $this->result; + Utils::$context['visual_verification_id'] = $this->id; + } + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Static wrapper for constructor that returns result (or error indicators). + * + * @param array &$options Options for the verification control. + * @param bool $do_test Whether to check to see if the user entered the code correctly. + * @return bool|array False if there's nothing to show, true if everything went well, or an array containing error indicators if the test failed. + */ + public static function create(array &$options, bool $do_test = false): bool|array + { + $obj = new self($options, $do_test); + + foreach ($options as $key => $value) { + $options[$key] = $obj->$key; + } + + return !empty($obj->errors) ? $obj->errors : $obj->result; + } + + /** + * Static wrapper for constructor that returns result (or error indicators). + * + * @param array &$options Options for the verification control. + * @return bool|array False if there's nothing to show, true if everything went well, or an array containing error indicators if the test failed. + */ + public static function verify(array &$options): bool|array + { + $obj = new self($options, true); + + foreach ($options as $key => $value) { + $options[$key] = $obj->$key; + } + + return !empty($obj->errors) ? $obj->errors : $obj->result; + } + + /****************** + * Internal methods + ******************/ + + /** + * Initializes some required template stuff. + */ + protected function init(): void + { + // The template + Theme::loadTemplate('GenericControls'); + + // Backward compatibility. + Utils::$context['controls']['verification'] = &self::$loaded; + } +} diff --git a/Sources/AntiSpam/index.php b/Sources/AntiSpam/index.php new file mode 100644 index 0000000000..cc9dd08570 --- /dev/null +++ b/Sources/AntiSpam/index.php @@ -0,0 +1,8 @@ +is_admin && !empty(Config::$modSettings['pm_posts_verification']) && User::$me->posts < Config::$modSettings['pm_posts_verification']; if (Utils::$context['require_verification']) { - $verifier = new Verifier(['id' => 'pm']); + new Verification(['id' => 'pm']); } IntegrationHook::call('integrate_pm_post'); @@ -1006,8 +1006,8 @@ public static function compose2(): bool // Wrong verification code? if (!User::$me->is_admin && !isset($_REQUEST['xml']) && !empty(Config::$modSettings['pm_posts_verification']) && User::$me->posts < Config::$modSettings['pm_posts_verification']) { - $verifier = new Verifier(['id' => 'pm']); - $post_errors = array_merge($post_errors, $verifier->errors); + $veriifcation = new Verification(['id' => 'pm'], true); + $post_errors = array_merge($post_errors, $veriifcation->errors); } // If they did, give a chance to make amends. @@ -2171,7 +2171,7 @@ public static function reportErrors(array $error_types, array $named_recipients, Utils::$context['require_verification'] = !User::$me->is_admin && !empty(Config::$modSettings['pm_posts_verification']) && User::$me->posts < Config::$modSettings['pm_posts_verification']; if (Utils::$context['require_verification'] && !isset($_REQUEST['xml'])) { - $verifier = new Verifier(['id' => 'pm']); + new Verification(['id' => 'pm']); } Utils::$context['to_value'] = empty($named_recipients['to']) ? '' : '"' . implode('", "', $named_recipients['to']) . '"'; diff --git a/Sources/Subs-Compat.php b/Sources/Subs-Compat.php index 7995831582..cff697f608 100644 --- a/Sources/Subs-Compat.php +++ b/Sources/Subs-Compat.php @@ -11811,7 +11811,7 @@ function entity_fix__callback(array $matches): string } /******************** - * Begin SMF\Verifier + * Begin SMF\AntiSpam\Verification ********************/ /** @@ -11823,7 +11823,7 @@ function entity_fix__callback(array $matches): string */ function create_control_verification(array &$options, bool $do_test = false): bool|array { - return SMF\Verifier::create($options, $do_test); + return SMF\AntiSpam\Verification::create($options, $do_test); } /********************************************************** diff --git a/Themes/default/GenericControls.template.php b/Themes/default/GenericControls.template.php index 2cbab501d2..0052daef7d 100644 --- a/Themes/default/GenericControls.template.php +++ b/Themes/default/GenericControls.template.php @@ -15,7 +15,7 @@ use SMF\Lang; use SMF\Theme; use SMF\Utils; -use SMF\Verifier; +use SMF\AntiSpam\Verification; /** * This function displays all the stuff you get with a richedit box - BBC, smileys, etc. @@ -150,101 +150,26 @@ function template_control_richedit_buttons($editor_id) * @param int|string $verify_id The verification control ID * @param string $display_type What type to display. Can be 'single' to only show one verification option or 'all' to show all of them * @param bool $reset Whether to reset the internal tracking counter - * @return bool False if there's nothing else to show, true if $display_type is 'single', nothing otherwise + * @return void|bool False if there's nothing else to show, true if $display_type is 'single', nothing otherwise */ function template_control_verification($verify_id, $display_type = 'all', $reset = false) { - $verify_context = Verifier::$loaded[$verify_id]; - - // Keep track of where we are. - if (empty($verify_context->tracking) || $reset) - $verify_context->tracking = 0; - - // How many items are there to display in total. - $total_items = count($verify_context->questions) + ($verify_context->show_visual || $verify_context->can_recaptcha ? 1 : 0); - - // If we've gone too far, stop. - if ($verify_context->tracking > $total_items) - return false; - - // Loop through each item to show them. - for ($i = 0; $i < $total_items; $i++) - { - // If we're after a single item only show it if we're in the right place. - if ($display_type == 'single' && $verify_context->tracking != $i) - continue; - - if ($display_type != 'single') + $i = 0; + foreach (Verification::$loaded[$verify_id] as $agent => $callable) { + if ($display_type != 'single') { echo '
'; - - // Display empty field, but only if we have one, and it's the first time. - if ($verify_context->empty_field && empty($i)) - echo ' -
- ', Lang::getTxt('visual_verification_hidden', file: 'General'), ' - -
'; - - // Do the actual stuff - if ($i == 0 && ($verify_context->show_visual || $verify_context->can_recaptcha)) - { - if ($verify_context->show_visual) - { - if (Utils::$context['use_graphic_library']) - echo ' - ', Lang::getTxt('visual_verification_description', file: 'General'), ''; - else - echo ' - ', Lang::getTxt('visual_verification_description', file: 'General'), ' - ', Lang::getTxt('visual_verification_description', file: 'General'), ' - ', Lang::getTxt('visual_verification_description', file: 'General'), ' - ', Lang::getTxt('visual_verification_description', file: 'General'), ' - ', Lang::getTxt('visual_verification_description', file: 'General'), ' - ', Lang::getTxt('visual_verification_description', file: 'General'), ''; - - echo ' -
- ', Lang::getTxt('visual_verification_sound', file: 'General'), ' / ', Lang::getTxt('visual_verification_request_new', file: 'General'), '', $display_type != 'quick_reply' ? '
' : '', '
- ', Lang::getTxt('visual_verification_description', file: 'General'), $display_type != 'quick_reply' ? '
' : '', ' - -
'; - } - - if ($verify_context->can_recaptcha) - { - $lang = Lang::getTxt(Lang::txtExists('lang_recaptcha', file: 'General') ? 'lang_recaptcha' : 'lang_dictionary', file: 'General'); - echo ' -
-
- '; - } - } - else - { - // Where in the question array is this question? - $qIndex = $verify_context->show_visual || $verify_context->can_recaptcha ? $i - 1 : $i; - - if (isset($verify_context->questions[$qIndex])) - echo ' -
- ', $verify_context->questions[$qIndex]['q'], ':
- questions[$qIndex]['is_error'] ? 'style="border: 1px red solid;"' : '', ' tabindex="', Utils::$context['tabindex']++, '" required> -
'; } + $callable(); + if ($display_type != 'single') echo ' -
'; + '; - // If we were displaying just one and we did it, break. - if ($display_type == 'single' && $verify_context->tracking == $i) - break; + ++$i; } - // Assume we found something, always. - $verify_context->tracking++; - // Tell something displaying piecemeal to keep going. if ($display_type == 'single') return true;