settings() : Component_Settings::get( 'channel:' . $channel->get_id(), $channel->get_fields() ); if ( ! empty( $values['_enabled'] ) ) { ++$channels_on; if ( $channel->supports( 'phone' ) ) { ++$phone_on; } } } $providers_on = 0; foreach ( Provider_Registry::all() as $provider ) { $values = Provider_Registry::settings( $provider ); if ( ! empty( $values['_enabled'] ) ) { ++$providers_on; } } return array( 'channels_on' => $channels_on, 'providers_on' => $providers_on, 'phone_channels_on' => $phone_on, 'captcha' => Captcha::is_enabled(), ); } /** * Prints the Channels tab. * * @return void */ public function render_channels() { $this->render_notice(); foreach ( Channel_Registry::all() as $channel ) { $key = $channel instanceof Abstract_Channel ? $channel->settings_key() : 'channel:' . $channel->get_id(); $values = $channel instanceof Abstract_Channel ? $channel->settings() : Component_Settings::get( $key, $channel->get_fields() ); $this->render_card( 'channel', $channel->get_id(), $key, $channel->get_label(), $channel->get_description(), $channel->get_fields(), $values, 'channels', $channel->supports( 'email' ) ? 'email' : 'phone' ); } } /** * Prints the Captcha tab. * * @return void */ public function render_captcha() { $this->render_notice(); $values = Captcha::settings(); $enabled = ! empty( $values['_enabled'] ); Admin_Ui::card_open( __( 'Security check', 'aslansheed-login' ), __( 'Cloudflare Turnstile, hCaptcha or Google reCAPTCHA v2.', 'aslansheed-login' ), array( 'class' => 'aslo-component', 'toggle' => array( 'name' => 'c[_enabled]', 'checked' => $enabled, 'id' => 'aslo-captcha-enabled', 'form' => 'aslo-form-captcha', ), ) ); Admin_Ui::collapse_open( $enabled, array( 'data-enabled-body' => '1' ) ); echo '
'; wp_nonce_field( self::NONCE_ACTION ); echo ''; echo ''; echo ''; foreach ( Captcha::fields() as $name => $field ) { $this->render_field( Captcha::KEY, $name, $field, isset( $values[ $name ] ) ? $values[ $name ] : '' ); } echo '
'; echo '
'; Admin_Ui::collapse_close(); Admin_Ui::card_close(); } /** * Prints the SMS providers tab. * * @return void */ public function render_providers() { $this->render_notice(); foreach ( Provider_Registry::all() as $provider ) { $this->render_card( 'provider', $provider->get_id(), Provider_Registry::settings_key( $provider ), $provider->get_label(), $provider->get_description(), $provider->get_fields(), Provider_Registry::settings( $provider ), 'providers', 'phone' ); } } /** * Saves one component. * * @return void */ public function handle_save() { $component = $this->resolve_component(); check_admin_referer( self::NONCE_ACTION ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce checked above; values are sanitized per field in Component_Settings::save(). $input = isset( $_POST['c'] ) && is_array( $_POST['c'] ) ? wp_unslash( $_POST['c'] ) : array(); Component_Settings::save( $component['key'], $component['fields'], $input ); Audit_Log::log( 'settings_changed', array( 'user_id' => get_current_user_id(), 'context' => array( 'component' => $component['key'] ), ) ); $this->redirect( $component['tab'], 'success', __( 'Settings saved.', 'aslansheed-login' ) ); } /** * Sends a test message through one component. * * @return void */ public function handle_test() { $component = $this->resolve_component(); check_admin_referer( self::NONCE_ACTION ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce checked above. $raw = isset( $_POST['recipient'] ) ? sanitize_text_field( wp_unslash( $_POST['recipient'] ) ) : ''; $type = Phone_Normalizer::detect_type( $raw ); if ( 'email' === $type ) { $recipient = sanitize_email( $raw ); } elseif ( 'phone' === $type ) { $recipient = Phone_Normalizer::to_e164( $raw, Access_Control::default_country() ); } else { $recipient = null; } if ( ! $recipient ) { $this->redirect( $component['tab'], 'error', __( 'Enter a valid phone number (with country code) or email address.', 'aslansheed-login' ) ); } $code = '000000'; $message = Message_Template::render( (string) Options::get( 'channels.message_template' ), $code, 'test' ); if ( 'channel' === $component['type'] ) { $channel = Channel_Registry::get( $component['id'] ); if ( ! $channel || ! $channel->supports( $type ) ) { $this->redirect( $component['tab'], 'error', __( 'This channel cannot deliver to that kind of recipient.', 'aslansheed-login' ) ); } $result = $channel->send( $recipient, 'email' === $type ? Message_Template::render( (string) Options::get( 'channels.email_body' ), $code, 'test' ) : $message, array( 'purpose' => 'test', 'code' => $code, 'subject' => Message_Template::render( (string) Options::get( 'channels.email_subject' ), $code, 'test' ), ) ); $log_id = $channel->get_id(); } else { $provider = Provider_Registry::get( $component['id'] ); if ( ! $provider || 'phone' !== $type ) { $this->redirect( $component['tab'], 'error', __( 'SMS providers need a phone number.', 'aslansheed-login' ) ); } $settings = Provider_Registry::settings( $provider ); foreach ( $provider->required_fields() as $name ) { if ( '' === trim( (string) $settings[ $name ] ) ) { $this->redirect( $component['tab'], 'error', __( 'Fill in and save the required fields first.', 'aslansheed-login' ) ); } } $result = $provider->send( $recipient, $message, $settings, array( 'code' => $code ) ); $log_id = 'sms'; } Message_Log::record( $log_id, $recipient, 'test', $result ); $this->redirect( $component['tab'], $result->success ? 'success' : 'error', $result->success ? __( 'The provider accepted the test message.', 'aslansheed-login' ) : sprintf( /* translators: %s: error returned by the provider */ __( 'Sending failed: %s', 'aslansheed-login' ), '' !== $result->message ? $result->message : $result->code ) ); } /** * Connects the Telegram bot webhook. * * @return void */ public function handle_connect_telegram() { $this->require_capability(); check_admin_referer( self::NONCE_ACTION ); $result = Telegram_Bot::connect(); Audit_Log::log( 'telegram_bot_connect', array( 'user_id' => get_current_user_id(), 'context' => array( 'ok' => $result['ok'] ), ) ); $this->redirect( 'channels', $result['ok'] ? 'success' : 'error', $result['message'] ); } /** * Prints one settings card. * * @param string $type "channel" or "provider". * @param string $id Component id. * @param string $key Settings key. * @param string $label Title. * @param string $help Description. * @param array $fields Field definitions. * @param array $values Current values. * @param string $tab Tab to return to. * @param string $accepts "phone" or "email", used for the test box hint. * @return void */ private function render_card( $type, $id, $key, $label, $help, array $fields, array $values, $tab, $accepts ) { unset( $tab ); $enabled = ! empty( $values['_enabled'] ); $form_id = 'aslo-form-' . sanitize_html_class( $type . '-' . $id ); $visible = array_filter( $fields, static function ( $field ) { return empty( $field['internal'] ); } ); Admin_Ui::card_open( $label, $help, array( 'class' => 'aslo-component', 'toggle' => array( 'name' => 'c[_enabled]', 'checked' => $enabled, 'id' => 'en-' . $key, 'form' => $form_id, ), ) ); Admin_Ui::collapse_open( $enabled, array( 'data-enabled-body' => '1' ) ); echo '
'; wp_nonce_field( self::NONCE_ACTION ); echo ''; echo ''; echo ''; if ( 'provider' === $type ) { Admin_Ui::row_open( __( 'Fallback order', 'aslansheed-login' ), __( 'When several gateways are on, lower numbers are tried first.', 'aslansheed-login' ), 'prio-' . $key ); echo ''; Admin_Ui::row_close(); } else { echo ''; } foreach ( $fields as $name => $field ) { if ( ! empty( $field['internal'] ) ) { continue; } $this->render_field( $key, $name, $field, isset( $values[ $name ] ) ? $values[ $name ] : '' ); } if ( array() !== $visible || 'provider' === $type ) { echo '
'; } echo '
'; if ( 'telegram_bot' === $id ) { $this->render_connect_box(); } echo '
'; wp_nonce_field( self::NONCE_ACTION ); echo ''; echo ''; echo ''; echo ''; echo '
'; echo '
'; echo '

'; echo '
'; Admin_Ui::collapse_close(); Admin_Ui::card_close(); } /** * Prints the "Connect bot" box of the Telegram bot channel. * * @return void */ private function render_connect_box() { echo '
'; wp_nonce_field( self::NONCE_ACTION ); echo ''; echo '' . esc_html( Telegram_Bot::webhook_url() ) . ''; echo '
' . esc_html__( 'Save the token first, then connect. This registers the webhook with Telegram.', 'aslansheed-login' ) . '
'; echo '

'; echo '
'; } /** * Prints one field row. * * @param string $key Component key. * @param string $name Field name. * @param array $field Field definition. * @param mixed $value Current value. * @return void */ private function render_field( $key, $name, array $field, $value ) { $id = 'f-' . sanitize_html_class( $key . '-' . $name ); $type = Component_Settings::type_of( $field ); $attr = 'name="' . esc_attr( 'c[' . $name . ']' ) . '" id="' . esc_attr( $id ) . '"'; $label = $field['label'] . ( ! empty( $field['required'] ) ? ' *' : '' ); Admin_Ui::row_open( $label, ! empty( $field['help'] ) ? (string) $field['help'] : '', $id, 'textarea' === $type ? array( 'class' => 'aslo-row-wide' ) : array() ); switch ( $type ) { case 'password': $has = Component_Settings::has_secret( $key, $name ); echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $attr is built from escaped parts. if ( Component_Settings::is_unreadable( $key, $name ) ) { echo '

' . esc_html__( 'The saved secret can no longer be decrypted because the site security keys changed. Enter it again.', 'aslansheed-login' ) . '

'; } break; case 'select': echo ''; break; case 'textarea': echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $attr is built from escaped parts. break; default: echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $attr is built from escaped parts. } Admin_Ui::row_close(); } /** * Reads and validates the component named in the request. * * @return array{type: string, id: string, key: string, fields: array, tab: string} */ private function resolve_component() { $this->require_capability(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce is verified by the calling handler right after this lookup. $type = isset( $_POST['component_type'] ) ? sanitize_key( wp_unslash( $_POST['component_type'] ) ) : ''; $id = isset( $_POST['component_id'] ) ? sanitize_key( wp_unslash( $_POST['component_id'] ) ) : ''; // phpcs:enable if ( 'channel' === $type ) { $channel = Channel_Registry::get( $id ); if ( $channel ) { return array( 'type' => 'channel', 'id' => $id, 'key' => $channel instanceof Abstract_Channel ? $channel->settings_key() : 'channel:' . $id, 'fields' => $channel->get_fields(), 'tab' => 'channels', ); } } elseif ( 'captcha' === $type ) { return array( 'type' => 'captcha', 'id' => 'captcha', 'key' => Captcha::KEY, 'fields' => Captcha::fields(), 'tab' => 'captcha', ); } elseif ( 'provider' === $type ) { $provider = Provider_Registry::get( $id ); if ( $provider ) { return array( 'type' => 'provider', 'id' => $id, 'key' => Provider_Registry::settings_key( $provider ), 'fields' => $provider->get_fields(), 'tab' => 'providers', ); } } wp_die( esc_html__( 'Unknown component.', 'aslansheed-login' ), '', array( 'response' => 400 ) ); } /** * Stops the request unless the user may manage options. * * @return void */ private function require_capability() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'aslansheed-login' ), '', array( 'response' => 403 ) ); } } /** * Stores a one-time notice and redirects back to a tab. * * @param string $tab Tab id. * @param string $type "success" or "error". * @param string $message Message text. * @return void */ private function redirect( $tab, $type, $message ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Every caller verified the nonce before getting here. if ( ! empty( $_POST['aslansheed_ajax'] ) ) { wp_send_json( array( 'ok' => 'success' === $type, 'message' => $message, ) ); } set_transient( 'aslansheed_notice_' . get_current_user_id(), array( 'type' => $type, 'message' => $message, ), 60 ); wp_safe_redirect( add_query_arg( array( 'page' => Settings_Page::PAGE_SLUG, 'tab' => $tab, ), admin_url( 'admin.php' ) ) ); exit; } /** * Prints and clears the pending notice. * * @return void */ private function render_notice() { $key = 'aslansheed_notice_' . get_current_user_id(); $notice = get_transient( $key ); if ( ! is_array( $notice ) ) { return; } delete_transient( $key ); printf( '

%2$s

', 'success' === $notice['type'] ? 'success' : 'error', esc_html( (string) $notice['message'] ) ); } }