<?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
 */

/**
 * Get a list of fields that should not be preserved when preserving POST fields on the login form.
 *
 * @return array List of fields
 */
function login_get_fields_to_not_relay() : array
{
    return ['username', 'password', 'remember', 'login_invisible', 'redirect', 'session_id'];
}

/**
 * Generate a log in screen.
 *
 * @param  Tempcode $title Screen title
 * @param  string $target The form target
 * @param  ?mixed $redirect Where to redirect to (null: redirect to same URL)
 * @return Tempcode The log in screen
 */
function generate_login_screen(object $title, string $target = '_top', $redirect = null) : object
{
    global $CURRENTLY_LOGGING_IN;
    $CURRENTLY_LOGGING_IN = true;

    $passion = new Tempcode(); // Hidden fields

    $fields_to_not_relay = login_get_fields_to_not_relay();

    // Where we will be redirected to after login, for GET requests (POST requests are handled further in the code)
    if ($redirect === null) {
        $redirect_default = get_self_url(true); // The default is to go back to where we are after login. This URL is coded to not redirect to root if we have $_POST, because we relay $_POST values and have intelligence (via $passion).
        $redirect = get_param_string('redirect', $redirect_default, INPUT_FILTER_URL_INTERNAL); // ... but often the login screen's URL tells us where to go back to
    }
    require_code('global4');
    if (($redirect != '') && (!is_unhelpful_redirect($redirect))) {
        $passion->attach(form_input_hidden('redirect', static_evaluate_tempcode(protect_url_parameter($redirect))));
    } else { // We will only go to the zone-default page if an explicitly blank redirect URL is given or if the redirect would take us direct to another login or logout page
        global $ZONE;
        $_url = build_url(['page' => $ZONE['zone_default_page']], '_SELF');
        $url = $_url->evaluate();
        $passion->attach(form_input_hidden('redirect', static_evaluate_tempcode(protect_url_parameter($url))));
    }

    // POST field relaying
    if (empty($_FILES)) { // Only if we don't have _FILES (which could never be relayed)
        $passion->attach(build_keep_post_fields($fields_to_not_relay));
        $redirect_passon = post_param_string('redirect', null, INPUT_FILTER_URL_INTERNAL & ~INPUT_FILTER_TRUSTED_SITES);
        if ($redirect_passon !== null) {
            $passion->attach(form_input_hidden('redirect_passon', static_evaluate_tempcode(protect_url_parameter($redirect_passon)))); // redirect_passon is used when there are POST fields, as it says what the redirect will be on the post-login-check hop (post fields prevent us doing an immediate HTTP-level redirect).
        }
    }

    if (either_param_string('_lead_source_description', '') == '') {
        global $METADATA;
        $_lead_source_description = (isset($METADATA['real_page']) ? $METADATA['real_page'] : get_page_name()) . ' (' . get_self_url_easy() . ')';
        $passion->attach(form_input_hidden('_lead_source_description', $_lead_source_description)); // Not specified, and we want the default for what the login screen is to be carried through (before real_page and get_self_url_easy is lost)
    }

    // Lost password link
    if (get_forum_type() == 'cns' && !has_interesting_post_fields()) {
        require_lang('cns');
        $lost_password_url = build_url(['page' => 'lost_password', 'wide_high' => get_param_integer('wide_high', null)], get_module_zone('lost_password'));
        $extra = do_lang_tempcode('cns:IF_FORGOTTEN_PASSWORD', escape_html($lost_password_url->evaluate()));
    } else {
        $extra = new Tempcode();
    }

    // It's just a session confirm to an existing cookie login, so we can make "remember me" ticked to avoid making user re-tick (check) it
    if (!is_guest() && isset($_COOKIE[get_member_cookie()]) && !$GLOBALS['SESSION_CONFIRMED_CACHE']) {
        $_POST['remember'] = '1';
    }

    // Render
    $login_url = build_url(['page' => 'login', 'type' => 'login'], '_SELF');
    require_css('login');
    $username = get_param_string('username', '', INPUT_FILTER_GET_IDENTIFIER);
    if (!is_guest()) {
        $username = $GLOBALS['FORUM_DRIVER']->get_username(get_member(), false, USERNAME_DEFAULT_BLANK);
    }
    return do_template('LOGIN_SCREEN', [
        '_GUID' => '0940dbf2c42493c53b7e99eb50ca51f1',
        'EXTRA' => $extra,
        'USERNAME' => $username,
        'JOIN_URL' => $GLOBALS['FORUM_DRIVER']->join_url(true),
        'TITLE' => $title,
        'LOGIN_URL' => $login_url,
        'PASSION' => $passion,
        'TARGET' => $target,
    ]);
}

/**
 * Backdoor handler. Can only be activated by those with FTP write-access.
 *
 * @return MEMBER The member to simulate
 */
function restricted_manually_enabled_backdoor() : int
{
    global $IS_A_COOKIE_LOGIN, $IS_VIA_BACKDOOR;
    $IS_A_COOKIE_LOGIN = true;

    require_code('users_inactive_occasionals');

    // Option 1: Create using SU
    $ks = get_param_string('keep_su', null);
    if ($ks !== null) {
        $su = $GLOBALS['FORUM_DRIVER']->get_member_from_username($ks);

        // We don't want session ID cookie to keep changing if we have keep_su in a separate tab, so make this false to stop it oscillating
        // It would break Commandr running.
        // Better to let session regenerate on each refresh.
        // For a non-backdoor SU we generate the session for the logged in admin account anyway, SU runs on top of that.
        $create_cookie = false;

        if (($su !== null) || (is_numeric($ks))) {
            if (get_param_integer('keep_su_strict', 0) == 0) {
                $GLOBALS['IS_ACTUALLY_ADMIN'] = true;
                $GLOBALS['SESSION_CONFIRMED'] = 1;
            }

            $is_invisible = (get_param_integer('keep_su_online', 0) == 0);
            $IS_VIA_BACKDOOR = true;

            if ($su !== null) {
                $ret = $su;
                create_session($ret, 1, $is_invisible, $create_cookie);
                return $ret;
            } else {
                $ret = intval($ks);
                create_session($ret, 1, $is_invisible, $create_cookie);
                return $ret;
            }
        } else {
            require_code('site');
            attach_message(do_lang_tempcode('_MEMBER_NO_EXIST', escape_html($ks)), 'warn');
        }
    }

    // Option 2: Create to first admin user
    $ret = get_first_admin_user();
    $IS_VIA_BACKDOOR = true;
    create_session($ret, 1); // Will restore from previous session if possible

    enforce_declarations($ret);

    return $ret;
}

/**
 * Get the first admin user.
 *
 * @return MEMBER Admin user
 */
function get_first_admin_user() : int
{
    $members = $GLOBALS['FORUM_DRIVER']->member_group_query($GLOBALS['FORUM_DRIVER']->get_super_admin_groups(), 1);
    if (!empty($members)) {
        $ret = $GLOBALS['FORUM_DRIVER']->mrow_member_id($members[key($members)]);
    } else {
        $ret = $GLOBALS['FORUM_DRIVER']->get_guest_id() + 1;
    }
    return $ret;
}

/**
 * Process a login.
 *
 * @param  ID_TEXT $username Username
 */
function handle_active_login(string $username)
{
    $result = [];

    $password = post_param_string('password', false, INPUT_FILTER_PASSWORD);
    $login_array = $GLOBALS['FORUM_DRIVER']->authorise_login($username, null, $password);
    $member_id = $login_array['id'];

    // Run hooks, if any exist
    $hooks = find_all_hook_obs('systems', 'upon_login', 'Hook_upon_login_');
    foreach ($hooks as $ob) {
        $ob->run(true, $username, $member_id); // true means "a new login attempt"
    }

    if ($member_id !== null) { // Valid user
        $remember = post_param_integer('remember', 0, INPUT_FILTER_DEFAULT_POST & ~INPUT_FILTER_TRUSTED_SITES);

        // Create invisibility cookie
        if ((array_key_exists(get_member_cookie() . '_invisible', $_COOKIE)/*i.e. already has cookie set, so adjust*/) || ($remember == 1)) {
            $invisible = post_param_integer('login_invisible', 0, INPUT_FILTER_DEFAULT_POST & ~INPUT_FILTER_TRUSTED_SITES);
            if ($invisible == 1) {
                cms_setcookie(get_member_cookie() . '_invisible', '1');
            } else {
                cms_eatcookie(get_member_cookie() . '_invisible');
            }
            $_COOKIE[get_member_cookie() . '_invisible'] = strval($invisible);
        }

        // Store the cookies
        if ($remember == 1) {
            global $IS_A_COOKIE_LOGIN;
            $IS_A_COOKIE_LOGIN = true;

            // Create user cookie
            if (method_exists($GLOBALS['FORUM_DRIVER'], 'create_login_cookie')) {
                $GLOBALS['FORUM_DRIVER']->create_login_cookie($member_id, $username, $password);
            }
        }

        // Create session
        require_code('users_inactive_occasionals');
        create_session($member_id, 1, post_param_integer('login_invisible', 0, INPUT_FILTER_DEFAULT_POST & ~INPUT_FILTER_TRUSTED_SITES) == 1);
        global $MEMBER_CACHED;
        $MEMBER_CACHED = $member_id;

        enforce_temporary_passwords($member_id);
        enforce_declarations($member_id);
    } elseif ($login_array['failed_login'] === true) {
        $GLOBALS['SITE_DB']->query_insert('failedlogins', [
            'failed_account' => cms_mb_substr(post_param_string('username', false, INPUT_FILTER_DEFAULT_POST & ~INPUT_FILTER_TRUSTED_SITES | INPUT_FILTER_TRIMMED), 0, 80),
            'date_and_time' => time(),
            'ip' => get_ip_address(),
        ]);

        $brute_force_login_minutes = intval(get_option('brute_force_login_minutes'));
        $brute_force_threshold = intval(get_option('brute_force_threshold'));

        $count = $GLOBALS['SITE_DB']->query_value_if_there('SELECT COUNT(*) FROM ' . get_table_prefix() . 'failedlogins WHERE date_and_time>' . strval(time() - 60 * $brute_force_login_minutes) . ' AND date_and_time<=' . strval(time()) . ' AND ' . db_string_equal_to('ip', get_ip_address()));
        if ($count >= $brute_force_threshold) {
            log_hack_attack_and_exit('BRUTEFORCE_LOGIN_HACK', $username);
        }
    }
}

/**
 * Process a logout.
 */
function handle_active_logout()
{
    // Kill cookie
    $GLOBALS['FORUM_DRIVER']->eat_login_cookie();

    // Kill session
    $session = get_session_id();
    if ($session != '') {
        delete_session($session);
    }

    // Update last-visited cookie
    if (get_forum_type() == 'cns') {
        require_code('users_active_actions');
        cms_setcookie('last_visit', strval(time()), true);
    }
}

/**
 * Actualiser for enforcing expired and temporary passwords. Will redirect_exit if the member needs to change their password.
 *
 * @param  MEMBER $member_id The current member
 *
 * @ignore
 */
function _enforce_temporary_passwords(int $member_id)
{
    $force_change_message = null;
    $redirect_url = null;

    $username = $GLOBALS['FORUM_DRIVER']->get_username($member_id);

    // Expired?
    if (intval(get_option('password_expiry_days')) > 0) {
        require_code('password_rules');
        require_code('crypt');
        if (member_password_expired($member_id)) {
            require_lang('password_rules');
            $force_change_message = do_lang_tempcode('PASSWORD_EXPIRED', escape_html($username), escape_html(integer_format(intval(get_option('password_expiry_days')))));
            require_code('urls');
            $redirect_url = build_url(['page' => 'lost_password', 'username' => $username], '');
        }
    }

    $password_compat_scheme = $GLOBALS['FORUM_DRIVER']->get_member_row_field($member_id, 'm_password_compat_scheme');

    // Temporary?
    if (($password_compat_scheme == 'temporary'/*LEGACY*/) || ($password_compat_scheme == 'bcrypt_temporary')) {
        require_lang('cns');
        $force_change_message = do_lang_tempcode('YOU_HAVE_TEMPORARY_PASSWORD', escape_html($username));
        require_code('urls');
        $redirect_url = build_url(['page' => 'members', 'type' => 'view', 'id' => $member_id], get_module_zone('members'), [], false, false, false, 'tab--edit--settings');
    } elseif (intval(get_option('password_change_days')) > 0) { // Too old?
        require_code('password_rules');
        if (member_password_too_old($member_id)) {
            require_lang('password_rules');
            $force_change_message = do_lang_tempcode('PASSWORD_TOO_OLD', escape_html($username), escape_html(integer_format(intval(get_option('password_change_days')))));
            require_code('urls');
            $redirect_url = build_url(['page' => 'members', 'type' => 'view', 'id' => $member_id], get_module_zone('members'), [], false, false, false, 'tab--edit--settings');
        }
    }

    if ($force_change_message !== null) {
        delete_cache_entry('side_users_online');

        require_code('urls');
        require_code('site2');
        require_lang('cns');

        redirect_exit($redirect_url, get_screen_title('LOGGED_IN'), $force_change_message, false, 'warn');
    }
}

/**
 * Actualiser for enforcing the member agreed to the latest declarations. Will redirect_exit if the member needs to re-accept declarations.
 *
 * @param  MEMBER $member_id The current member
 *
 * @ignore
 */
function _enforce_declarations(int $member_id)
{
    // Enforcement is supported when not using Conversr, but Conversr must still be installed
    if (!addon_installed('core_cns')) {
        return;
    }

    $force_message = null;
    $redirect_url = null;

    $configured_declarations = trim(preg_replace('#\n+#', "\n", get_option('join_declarations')));

    $cpfs = $GLOBALS['FORUM_DRIVER']->get_custom_fields($member_id);
    if ($cpfs === null) {
        warn_exit(do_lang_tempcode('INTERNAL_ERROR'));
    }
    if (!isset($cpfs['agreed_declarations'])) {
        $cpfs['agreed_declarations'] = '';
    }
    $agreed_declarations = trim(preg_replace('#\n+#', "\n", $cpfs['agreed_declarations']));

    if ($agreed_declarations == '') {
        require_lang('cns');
        require_code('urls');
        $force_message = do_lang_tempcode('DESCRIPTION_I_AGREE_RULES_USE');
        $redirect_url = build_url(['page' => 'join', 'type' => 'review_rules'], get_module_zone('join'));
    } elseif ($agreed_declarations != $configured_declarations) {
        require_lang('cns');
        require_code('urls');
        $force_message = do_lang_tempcode('DESCRIPTION_I_AGREE_RULES_UPDATED');
        $redirect_url = build_url(['page' => 'join', 'type' => 'review_rules'], get_module_zone('join'));
    }

    if ($force_message !== null) {
        delete_cache_entry('side_users_online');

        require_code('urls');
        require_code('site2');
        require_lang('cns');

        redirect_exit($redirect_url, get_screen_title('_I_AGREE_RULES'), $force_message, false, 'warn');
    }
}

/**
 * Delete a session.
 *
 * @param  ID_TEXT $session The session to delete
 */
function delete_session(string $session)
{
    if ($session == get_session_id()) {
        require_code('users_inactive_occasionals');
        set_session_id('');
    }

    $GLOBALS['SITE_DB']->query_delete('sessions', ['the_session' => $session], '', 1);

    global $SESSION_CACHE;
    unset($SESSION_CACHE[$session]);
    if (get_option('session_prudence') == '0') {
        persistent_cache_set('SESSION_CACHE', $SESSION_CACHE);
    }
}

/**
 * Delete a session by member ID.
 *
 * @param  MEMBER $member_id The member ID whose sessions should be deleted
 * @param  ?ID_TEXT $except_session A session to not delete (null: no filter)
 */
function delete_session_by_member_id(int $member_id, ?string $except_session = null)
{
    if ($except_session === null) {
        $GLOBALS['SITE_DB']->query_delete('sessions', ['member_id' => $member_id]);
    } else {
        $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'sessions WHERE member_id=' . strval($member_id) . ' AND ' . db_string_not_equal_to('the_session', $except_session));
    }

    global $SESSION_CACHE;
    foreach ($SESSION_CACHE as $session => $session_details) {
        if (($session_details['member_id'] == $member_id) && ($session !== $except_session)) {
            unset($SESSION_CACHE[$session]);
        }
    }
    if (get_option('session_prudence') == '0') {
        persistent_cache_set('SESSION_CACHE', $SESSION_CACHE);
    }
}

/**
 * Set invisibility on the current user.
 *
 * @param  boolean $make_invisible Whether to make the current user invisible (true=make invisible, false=make visible)
 */
function set_invisibility(bool $make_invisible = true)
{
    $GLOBALS['SITE_DB']->query_update('sessions', ['session_invisible' => $make_invisible ? 1 : 0], ['member_id' => get_member(), 'the_session' => get_session_id()], '', 1);
    global $SESSION_CACHE;
    if ($SESSION_CACHE[get_session_id()]['member_id'] == get_member()) { // A little security
        $SESSION_CACHE[get_session_id()]['session_invisible'] = $make_invisible ? 1 : 0;
        if (get_option('session_prudence') == '0') {
            persistent_cache_set('SESSION_CACHE', $SESSION_CACHE);
        }
    }

    delete_cache_entry('side_users_online');

    // Store in cookie, if we have login cookies around
    if (array_key_exists(get_member_cookie(), $_COOKIE)) {
        require_code('users_active_actions');
        if ($make_invisible) {
            cms_setcookie(get_member_cookie() . '_invisible', '1');
        } else {
            cms_eatcookie(get_member_cookie() . '_invisible');
        }
        $_COOKIE[get_member_cookie() . '_invisible'] = strval($make_invisible ? 1 : 0);
    }
}

/**
 * Deletes a cookie (if it exists), from within the site's cookie environment.
 *
 * @param  string $name The name of the cookie
 * @return boolean The result of the PHP setcookie command
 */
function cms_eatcookie(string $name) : bool
{
    $expire = time() - 100000; // Note the negative number must be greater than 13*60*60 to account for maximum timezone difference

    // Try and remove other potentials
    @setcookie($name, '', $expire, '', preg_replace('#^www\.#', '', get_request_hostname()));
    @setcookie($name, '', $expire, '/', preg_replace('#^www\.#', '', get_request_hostname()));
    @setcookie($name, '', $expire, '', 'www.' . preg_replace('#^www\.#', '', get_request_hostname()));
    @setcookie($name, '', $expire, '/', 'www.' . preg_replace('#^www\.#', '', get_request_hostname()));
    @setcookie($name, '', $expire, '', '');
    @setcookie($name, '', $expire, '/', '');

    // Delete standard potential
    return @setcookie($name, '', $expire, get_cookie_path(), get_cookie_domain());
}
