<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  Christopher Graham
 * @package    core_cns
 */

/**
 * Module page class.
 */
class Module_lost_password
{
    /**
     * Find details of the module.
     *
     * @return ?array Map of module info (null: module is disabled)
     */
    public function info() : ?array
    {
        $info = [];
        $info['author'] = 'Chris Graham';
        $info['organisation'] = 'Composr';
        $info['hacked_by'] = null;
        $info['hack_version'] = null;
        $info['version'] = 2;
        $info['locked'] = false;
        $info['min_cms_version'] = 11.0;
        $info['addon'] = 'core_cns';
        return $info;
    }

    public $title;

    /**
     * Module pre-run function. Allows us to know metadata for <head> before we start streaming output.
     *
     * @return ?Tempcode Tempcode indicating some kind of exceptional output (null: none)
     */
    public function pre_run() : ?object
    {
        if (get_forum_type() != 'cns') {
            warn_exit(do_lang_tempcode('NO_CNS'));
        }

        $type = get_param_string('type', 'browse');

        require_lang('cns');
        require_lang('cns_lost_password');
        require_css('cns');

        if ($type == 'browse') {
            breadcrumb_set_self(do_lang_tempcode('LOST_PASSWORD'));

            $this->title = get_screen_title('LOST_PASSWORD');
        }

        if ($type == 'step2') {
            breadcrumb_set_parents([['_SELF:_SELF:browse', do_lang_tempcode('LOST_PASSWORD')]]);
            breadcrumb_set_self(do_lang_tempcode('LOST_PASSWORD'));

            $this->title = get_screen_title('LOST_PASSWORD');
        }

        if ($type == 'step3') {
            $this->title = get_screen_title('LOST_PASSWORD');
        }

        return null;
    }

    /**
     * Execute the module.
     *
     * @return Tempcode The result of execution
     */
    public function run() : object
    {
        cns_require_all_forum_stuff();

        require_code('cns_lost_password');

        $type = get_param_string('type', 'browse');

        if ($type == 'browse') {
            return $this->step1();
        }
        if ($type == 'step2') {
            return $this->step2();
        }
        if ($type == 'step3') {
            return $this->step3();
        }

        return new Tempcode();
    }

    /**
     * Find entry-points available within this module.
     *
     * @param  boolean $check_perms Whether to check permissions
     * @param  ?MEMBER $member_id The member to check permissions as (null: current user)
     * @param  boolean $support_crosslinks Whether to allow cross links to other modules (identifiable via a full-page-link rather than a screen-name)
     * @param  boolean $be_deferential Whether to avoid any entry-point (or even return null to disable the page in the Sitemap) if we know another module, or page_group, is going to link to that entry-point. Note that "!" and "browse" entry points are automatically merged with container page nodes (likely called by page-groupings) as appropriate.
     * @return ?array A map of entry points (screen-name=>language-code/string or screen-name=>[language-code/string, icon-theme-image]) (null: disabled)
     */
    public function get_entry_points(bool $check_perms = true, ?int $member_id = null, bool $support_crosslinks = true, bool $be_deferential = false) : ?array
    {
        if (get_forum_type() != 'cns') {
            return null;
        }

        if ($check_perms && is_guest($member_id)) {
            return [
                'browse' => ['LOST_PASSWORD', 'menu/site_meta/user_actions/lost_password'],
            ];
        }
        return [];
    }

    /**
     * Get an account-specifier field inputter.
     *
     * @return Tempcode The field inputter
     */
    protected function input_identifying_field() : object
    {
        require_code('form_templates');

        $set_name = 'account';
        $required = true;
        $set_title = do_lang_tempcode('ACCOUNT');
        $field_set = alternate_fields_set__start($set_name);

        $field_set->attach(form_input_email(do_lang_tempcode('EMAIL_ADDRESS'), '', 'email', get_param_string('email', '', INPUT_FILTER_GET_IDENTIFIER), false));

        $field_set->attach(form_input_line(do_lang_tempcode('USERNAME'), '', 'username', get_param_string('username', '', INPUT_FILTER_GET_IDENTIFIER), false));
        // form_input_username not used, so as to stop someone accidentally autocompleting to someone else's similar name - very possible for a person already known to be forgetful

        return alternate_fields_set__end($set_name, $set_title, '', $field_set, $required);
    }

    /**
     * The UI to ask for the username to get the lost password for.
     *
     * @return Tempcode The UI
     */
    public function step1() : object
    {
        $password_reset_process = get_password_reset_process();
        $temporary_passwords = ($password_reset_process != 'emailed');

        $text = do_lang_tempcode('_PASSWORD_RESET_TEXT_' . $password_reset_process);

        $fields = new Tempcode();
        $hidden = new Tempcode();

        $fields->attach($this->input_identifying_field());

        if (addon_installed('captcha')) {
            require_code('captcha');
            if (use_captcha()) {
                $fields->attach(form_input_captcha($hidden));
                $text->attach(' ');
                $text->attach(do_lang_tempcode('captcha:FORM_TIME_SECURITY'));
            }
        }

        $submit_name = do_lang_tempcode('PASSWORD_RESET_BUTTON');
        $post_url = build_url(['page' => '_SELF', 'type' => 'step2'], '_SELF');

        return do_template('FORM_SCREEN', [
            '_GUID' => '080e516fef7c928dbb9fb85beb6e435a',
            'JS_FUNCTION_CALLS' => ((function_exists('captcha_ajax_check_function')) && (captcha_ajax_check_function() != '')) ? [captcha_ajax_check_function()] : [],
            'SKIP_WEBSTANDARDS' => true,
            'TITLE' => $this->title,
            'HIDDEN' => $hidden,
            'FIELDS' => $fields,
            'TEXT' => $text,
            'SUBMIT_ICON' => 'menu/site_meta/user_actions/lost_password',
            'SUBMIT_NAME' => $submit_name,
            'URL' => $post_url,
        ]);
    }

    /**
     * The UI and actualisation for sending out the confirmation e-mail.
     *
     * @return Tempcode The UI
     */
    public function step2() : object
    {
        if (addon_installed('captcha')) {
            require_code('captcha');
            enforce_captcha();
        }

        $_username = post_param_string('username', '', INPUT_FILTER_POST_IDENTIFIER);
        $_email = post_param_string('email', '', INPUT_FILTER_POST_IDENTIFIER);

        list($email, $member_id) = lost_password_emailer_step($_username, $_email);

        $password_reset_process = get_password_reset_process();

        if ($password_reset_process == 'ultra') {
            // Input UI (as code will be typed immediately, there's no link in the e-mail for 'ultra' mode)
            $zone = get_module_zone('lost_password');
            $_url = build_url(['page' => 'lost_password', 'type' => 'step3', 'email' => $_email, 'username' => $_username], $zone);
            require_code('form_templates');
            $fields = new Tempcode();
            $fields->attach(form_input_line(do_lang_tempcode('CODE'), '', 'code', null, true));
            $submit_name = do_lang_tempcode('PROCEED');
            return do_template('FORM_SCREEN', [
                '_GUID' => '9f03d4abe0140559ec6eba2fa34fe3d6',
                'TITLE' => $this->title,
                'GET' => true,
                'SKIP_WEBSTANDARDS' => true,
                'HIDDEN' => '',
                'URL' => $_url,
                'FIELDS' => $fields,
                'TEXT' => do_lang_tempcode('ENTER_CODE_FROM_EMAIL'),
                'SUBMIT_ICON' => 'menu/site_meta/user_actions/lost_password',
                'SUBMIT_NAME' => $submit_name,
            ]);
        }

        // Generate message
        $mailed_message = lost_password_mailed_message($password_reset_process, $email);

        return inform_screen($this->title, $mailed_message);
    }

    /**
     * The UI and actualisation for: accepting code if it is correct (and not ''), and setting password to something random, e-mailing it.
     *
     * @return Tempcode The UI
     */
    public function step3() : object
    {
        // We need to disable static caching for 'ultra' mode to work (it is a GET request, so static cache may be enabled otherwise)
        require_code('static_cache');
        disable_static_caching();

        $password_reset_process = get_password_reset_process();

        // Lookup code
        $code = get_param_string('code', '', INPUT_FILTER_GET_IDENTIFIER);
        if ($code == '') {
            // Code being manually typed
            $fields = new Tempcode();
            $fields->attach($this->input_identifying_field());
            $fields->attach(form_input_line(do_lang_tempcode('CODE'), '', 'code', null, true));

            $submit_name = do_lang_tempcode('PROCEED');

            return do_template('FORM_SCREEN', [
                '_GUID' => '6e4db5c6f3c75faa999251339533d22a',
                'TITLE' => $this->title,
                'GET' => true,
                'SKIP_WEBSTANDARDS' => true,
                'HIDDEN' => '',
                'URL' => get_self_url(false, false, [], false, true),
                'FIELDS' => $fields,
                'TEXT' => do_lang_tempcode('MISSING_CONFIRM_CODE'),
                'SUBMIT_ICON' => 'buttons/proceed',
                'SUBMIT_NAME' => $submit_name,
            ]);
        }

        // Find member involved
        $member_id = get_param_integer('member', null);
        if ($member_id === null) {
            $username = get_param_string('username', null, INPUT_FILTER_GET_IDENTIFIER);
            if ($username !== null) {
                $member_id = $GLOBALS['FORUM_DRIVER']->get_member_from_username($username);
                if (($member_id === null) || (is_guest($member_id))) {
                    if (get_option('password_reset_privacy') != 'disclose') {
                        warn_exit(do_lang_tempcode('INCORRECT_PASSWORD_RESET_CODE')); // This is a lie we have to give. They wouldn't have been given a reset code to try though!
                    }
                    warn_exit(do_lang_tempcode('PASSWORD_RESET_ERROR_ACCOUNT_NOT_FOUND'));
                }
            } else {
                $email = get_param_string('email', null, INPUT_FILTER_GET_IDENTIFIER);
                if ($email !== null) {
                    $member_id = $GLOBALS['FORUM_DRIVER']->get_member_from_email_address($email);
                    if ($member_id === null) {
                        if (get_option('password_reset_privacy') != 'disclose') {
                            warn_exit(do_lang_tempcode('INCORRECT_PASSWORD_RESET_CODE')); // This is a lie we have to give. They wouldn't have been given a reset code to try though!
                        }
                        warn_exit(do_lang_tempcode('PASSWORD_RESET_ERROR_ACCOUNT_NOT_FOUND'));
                    }
                } else {
                    warn_exit(do_lang_tempcode('PASSWORD_RESET_ERROR_NO_ACCOUNT_GIVEN'));
                }
            }
        }

        // Check code given is valid
        $correct_code = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_change_code');
        if ($correct_code == '') {
            if (get_member() == $member_id) { // Already reset and already logged in
                $redirect_url = build_url(['page' => 'members', 'type' => 'view', 'id' => $member_id], get_module_zone('members'), [], false, false, false, 'tab--edit--settings');
                return redirect_screen($this->title, $redirect_url);
            }

            $_reset_url = build_url(['page' => '_SELF', 'username' => $GLOBALS['FORUM_DRIVER']->get_username($member_id, false, USERNAME_DEFAULT_BLANK)], '_SELF');
            $reset_url = $_reset_url->evaluate();
            warn_exit(do_lang_tempcode('PASSWORD_ALREADY_RESET', escape_html($reset_url), get_site_name()));
        }
        if ($password_reset_process == 'ultra') {
            list($correct_code, $correct_session) = explode('__', $correct_code);
            if ($correct_session != get_session_id()) {
                warn_exit(do_lang_tempcode('WRONG_RESET_SESSION', escape_html(display_time_period(60 * 60 * intval(get_option('session_expiry_time'))))));
            }
        }
        if ($code != $correct_code) {
            $test = $GLOBALS['SITE_DB']->query_select_value_if_there('actionlogs', 'date_and_time', ['the_type' => 'LOST_PASSWORD', 'param_a' => strval($member_id), 'param_b' => $code]);
            if ($test !== null) {
                warn_exit(do_lang_tempcode('INCORRECT_PASSWORD_RESET_CODE')); // Just an old code that has expired
            }
            log_hack_attack_and_exit('HACK_ATTACK_PASSWORD_CHANGE'); // Incorrect code, hack-attack
        }
        $correct_code_time = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_change_code_time');
        if ($correct_code_time < time() - 60 * 60 * intval(get_option('password_reset_minutes'))) {
            warn_exit(do_lang_tempcode('RESET_CODE_EXPIRED', escape_html(display_time_period(60 * intval(get_option('password_reset_minutes'))))));
        }

        // Lookup member details
        $email = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_email_address');
        $join_time = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_join_time');
        $username = $GLOBALS['FORUM_DRIVER']->get_username($member_id);

        // Generate new password
        require_code('crypt');
        $new_password = get_secure_random_password(null, $username, $email);

        $temporary_passwords = ($password_reset_process != 'emailed');

        if (!$temporary_passwords) {
            // Send password in mail
            require_code('mail');
            $subject = do_lang('MAIL_NEW_PASSWORD_SUBJECT', get_site_name());
            $_login_url = build_url(['page' => 'login', 'type' => 'browse', 'username' => $GLOBALS['FORUM_DRIVER']->get_username($member_id)], get_module_zone('login'), [], false, false, true);
            $login_url = $_login_url->evaluate();
            $account_edit_url = build_url(['page' => 'members', 'type' => 'view'], get_module_zone('members'), [], false, false, true, 'tab--edit');
            if (get_option('one_per_email_address') != '0') {
                $lang_string = 'MAIL_NEW_PASSWORD_TEXT_EMAIL_LOGIN';
            } else {
                $lang_string = 'MAIL_NEW_PASSWORD_TEXT';
            }
            $message = do_lang($lang_string, comcode_escape($new_password), $login_url, [comcode_escape(get_site_name()), comcode_escape($username), $account_edit_url->evaluate(), comcode_escape($email)]);
            dispatch_mail($subject, $message, [$email], $GLOBALS['FORUM_DRIVER']->get_username($member_id, true), '', '', ['require_recipient_valid_since' => $join_time]);
        }

        // Update stored password
        if ((get_value('disable_password_hashing') === '1') && (!$temporary_passwords)) {
            $password_compatibility_scheme = 'plain';
            $new = $new_password;
        } else {
            require_code('crypt');
            $password_compatibility_scheme = ($temporary_passwords ? 'temporary' : '');
            $salt = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_pass_salt');
            $new = ratchet_hash($new_password, $salt);
        }
        $password_change_days = get_option('password_change_days');
        if (intval($password_change_days) > 0) {
            if ($password_compatibility_scheme == '') {
                require_code('password_rules');
                bump_password_change_date($member_id, $new_password, $new, $salt, false);
            }
        }

        // Mark temporary reset code used and invalidate login key and active sessions
        unset($_GET['code']);
        $update_map = [
            'm_validated_email_confirm_code' => '',
            'm_password_compat_scheme' => $password_compatibility_scheme,
            'm_pass_hash_salted' => $new,
            'm_login_key_hash' => '',
        ];
        if (!$temporary_passwords) {
            // Mark code used if it has instant effect only - otherwise we cannot do it as the link could have been pre-clicked by a virus scanner - rely on password_reset_minutes to expire it
            $update_map += [
                'm_password_change_code' => '',
                'm_password_change_code_time' => null,
            ];
        }
        $GLOBALS['FORUM_DB']->query_update('f_members', $update_map, ['id' => $member_id], '', 1);
        require_code('users_active_actions');
        delete_session_by_member_id($member_id);

        // Logging
        log_it('LOST_PASSWORD_FINALISE', strval($member_id), $username);

        // For temporary passwords: Log them in, then invite them to change their password
        if ($temporary_passwords) {
            require_code('users_inactive_occasionals');
            create_session($member_id, 1);

            $redirect_url = build_url(['page' => 'members', 'type' => 'view', 'id' => $member_id], get_module_zone('members'), [], false, false, false, 'tab--edit--settings');
            $username = $GLOBALS['FORUM_DRIVER']->get_username($member_id);
            $GLOBALS['FORCE_META_REFRESH'] = true; // Some browsers can't set cookies and redirect at the same time
            return redirect_screen($this->title, $redirect_url, do_lang_tempcode('YOU_HAVE_TEMPORARY_PASSWORD', escape_html($username)));
        }

        // Otherwise: Email new password
        return inform_screen($this->title, do_lang_tempcode('NEW_PASSWORD_MAILED', escape_html($email), escape_html($new_password)));
    }
}
