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

/**
 * Module page class.
 */
class Module_admin_security
{
    /**
     * 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'] = 6;
        $info['update_require_upgrade'] = true;
        $info['locked'] = true;
        $info['min_cms_version'] = 11.0;
        $info['addon'] = 'securitylogging';
        return $info;
    }

    /**
     * Uninstall the module.
     */
    public function uninstall()
    {
        $GLOBALS['SITE_DB']->drop_table_if_exists('hackattack');
    }

    /**
     * Install the module.
     *
     * @param  ?integer $upgrade_from What version we're upgrading from (null: new install)
     * @param  ?integer $upgrade_from_hack What hack version we're upgrading from (null: new-install/not-upgrading-from-a-hacked-version)
     */
    public function install(?int $upgrade_from = null, ?int $upgrade_from_hack = null)
    {
        if ($upgrade_from === null) {
            $GLOBALS['SITE_DB']->create_table('hackattack', [
                'id' => '*AUTO',
                'url' => 'URLPATH',
                'data_post' => 'LONG_TEXT',
                'user_agent' => 'SHORT_TEXT',
                'referer' => 'SHORT_TEXT',
                'user_os' => 'SHORT_TEXT',
                'member_id' => 'MEMBER',
                'date_and_time' => 'TIME',
                'ip' => 'IP',
                'reason' => 'ID_TEXT',
                'reason_param_a' => 'SHORT_TEXT',
                'reason_param_b' => 'SHORT_TEXT',
                'risk_score' => 'INTEGER',
                'silent_to_staff_log' => 'BINARY',
            ]);
            $GLOBALS['SITE_DB']->create_index('hackattack', 'otherhacksby', ['ip']);
            $GLOBALS['SITE_DB']->create_index('hackattack', 'h_date_and_time', ['date_and_time']);
        }

        if (($upgrade_from !== null) && ($upgrade_from < 3)) { // LEGACY
            $GLOBALS['SITE_DB']->add_table_field('hackattack', 'user_agent', 'SHORT_TEXT');
            $GLOBALS['SITE_DB']->add_table_field('hackattack', 'referer', 'SHORT_TEXT');
            $GLOBALS['SITE_DB']->add_table_field('hackattack', 'user_os', 'SHORT_TEXT');
        }

        if (($upgrade_from !== null) && ($upgrade_from < 4)) { // LEGACY
            $GLOBALS['SITE_DB']->alter_table_field('hackattack', 'the_user', 'MEMBER', 'member_id');
        }

        if (($upgrade_from !== null) && ($upgrade_from < 5)) { // LEGACY
            $GLOBALS['SITE_DB']->add_table_field('hackattack', 'percentage_score', 'INTEGER', 10);
            $GLOBALS['SITE_DB']->add_table_field('hackattack', 'silent_to_staff_log', 'BINARY');
        }

        if (($upgrade_from !== null) && ($upgrade_from < 6)) { // LEGACY: 11 beta4
            // We are using scores instead of percents now as it is easier to understand in configuration
            $GLOBALS['SITE_DB']->alter_table_field('hackattack', 'percentage_score', 'INTEGER', 'risk_score');

            // Multiply Hack-attack risk score ban threshold by 10; we are no longer dividing by 100 when checking for auto-bans
            $hack_threshold = intval(get_option('hack_ban_threshold'));
            set_option('hack_ban_threshold', strval($hack_threshold * 10));

            // All current records should be divided by 10 because generally risk scores are about 1/10 of what percentages were
            $start = 0;
            $max = 200;
            $rows = [];
            do {
                $rows = $GLOBALS['SITE_DB']->query_select('hackattack', ['id', 'risk_score'], [], '', $max, $start);
                foreach ($rows as $row) {
                    $GLOBALS['SITE_DB']->query_update('hackattack', ['risk_score' => intval(ceil($row['risk_score'] / 10))], ['id' => $row['id']]);
                }
                $start += $max;
            } while (!empty($rows));
        }
    }

    /**
     * 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 (!addon_installed('securitylogging')) {
            return null;
        }

        return [
            'browse' => ['SECURITY_LOG', 'menu/adminzone/audit/security_log'],
        ];
    }

    public $title;
    public $id;
    public $row;

    /**
     * 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
    {
        $error_msg = new Tempcode();
        if (!addon_installed__messaged('securitylogging', $error_msg)) {
            return $error_msg;
        }

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

        require_lang('security');

        set_helper_panel_tutorial('tut_security');

        if ($type == 'browse') {
            inform_non_canonical_parameter('alert_sort');
            inform_non_canonical_parameter('failed_sort');

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

        if ($type == 'clean') {
            $this->title = get_screen_title('SECURITY_LOG');
        }

        if ($type == 'view') {
            breadcrumb_set_parents([['_SELF:_SELF:browse', do_lang_tempcode('SECURITY_LOG')]]);

            $id = get_param_integer('id');
            $rows = $GLOBALS['SITE_DB']->query_select('hackattack', ['*'], ['id' => $id], '', 1);
            $row = $rows[0];

            $date = get_timezoned_date_time($row['date_and_time']);

            $this->title = get_screen_title('VIEW_ALERT', true, [escape_html($date)]);

            $this->id = $id;
            $this->row = $row;
        }

        return null;
    }

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

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

        if ($type == 'browse') {
            return $this->security_interface();
        }
        if ($type == 'clean') {
            return $this->clean_alerts();
        }
        if ($type == 'view') {
            return $this->alert_view();
        }

        return new Tempcode();
    }

    /**
     * The UI to view security logs.
     *
     * @return Tempcode The UI
     */
    public function security_interface() : object
    {
        // Failed logins...

        $start = get_param_integer('failed_start', 0);
        $max = get_param_integer('failed_max', 25);

        $sortables = ['date_and_time' => do_lang_tempcode('DATE_TIME'), 'ip' => do_lang_tempcode('IP_ADDRESS')];
        $test = explode(' ', get_param_string('failed_sort', 'date_and_time DESC', INPUT_FILTER_GET_COMPLEX));
        if (count($test) == 1) {
            $test[1] = 'DESC';
        }
        list($_sortable, $sort_order) = $test;
        if (((cms_strtoupper_ascii($sort_order) != 'ASC') && (cms_strtoupper_ascii($sort_order) != 'DESC')) || (!array_key_exists($_sortable, $sortables))) {
            log_hack_attack_and_exit('ORDERBY_HACK');
        }

        require_code('templates_results_table');

        $header_row = results_header_row([do_lang_tempcode('USERNAME'), do_lang_tempcode('DATE_TIME'), do_lang_tempcode('IP_ADDRESS')], $sortables, 'failed_sort', $_sortable . ' ' . $sort_order);

        $member_id = post_param_integer('member_id', null);
        $map = ($member_id !== null) ? ['failed_account' => $GLOBALS['FORUM_DRIVER']->get_username($member_id, false, USERNAME_DEFAULT_NULL)] : [];

        $max_rows = $GLOBALS['SITE_DB']->query_select_value('failedlogins', 'COUNT(*)', $map);

        $rows = $GLOBALS['SITE_DB']->query_select('failedlogins', ['*'], $map, 'ORDER BY ' . $_sortable . ' ' . $sort_order, $max, $start);

        $result_entries = new Tempcode();
        foreach ($rows as $row) {
            $date = get_timezoned_date_time($row['date_and_time']);
            $lookup_url = build_url(['page' => 'admin_lookup', 'type' => 'results', 'param' => $row['ip']], '_SELF');
            $result_entries->attach(results_entry([$row['failed_account'], $date, hyperlink($lookup_url, $row['ip'], false, true)], true));
        }

        $failed_logins = results_table(do_lang_tempcode('FAILED_LOGINS'), $start, 'failed_start', $max, 'failed_max', $max_rows, $header_row, $result_entries, $sortables, $_sortable, $sort_order, 'failed_sort', new Tempcode());

        // Hack-attacks...

        $member_id = post_param_integer('member_id', null);
        $map = ($member_id !== null) ? ['member_id' => $member_id] : [];
        list($alerts, $num_alerts) = find_security_alerts($map);

        // Render UI...

        $post_url = build_url(['page' => '_SELF', 'type' => 'clean', 'start' => $start, 'max' => $max], '_SELF');

        $tpl = do_template('SECURITY_SCREEN', [
            '_GUID' => 'e0b5e6557686b2320a8ce8166df07328',
            'TITLE' => $this->title,
            'FAILED_LOGINS' => $failed_logins,
            'NUM_FAILED_LOGINS' => strval(count($rows)),
            'ALERTS' => $alerts,
            'NUM_ALERTS' => strval($num_alerts),
            'URL' => $post_url,
        ]);

        require_code('templates_internalise_screen');
        return internalise_own_screen($tpl);
    }

    /**
     * Actualiser to delete some unwanted alerts.
     *
     * @return Tempcode The success/redirect screen
     */
    public function clean_alerts() : object
    {
        // Actualiser
        $count = 0;
        foreach (array_keys($_REQUEST) as $key) {
            if (substr($key, 0, 4) == 'del_') {
                $GLOBALS['SITE_DB']->query_delete('hackattack', ['id' => intval(substr($key, 4))], '', 1);
                $count++;
            }
        }

        if ($count == 0) {
            warn_exit(do_lang_tempcode('NOTHING_SELECTED'), false, false, 400);
        }

        // Redirect
        $url = build_url(['page' => '_SELF', 'type' => 'browse', 'start' => get_param_integer('start'), 'max' => get_param_integer('max')], '_SELF');
        return redirect_screen($this->title, $url, do_lang_tempcode('SUCCESS'));
    }

    /**
     * The UI to view a security alert.
     *
     * @return Tempcode The UI
     */
    public function alert_view() : object
    {
        $id = $this->id;
        $row = $this->row;

        $lookup_url = build_url(['page' => 'admin_lookup', 'type' => 'results', 'param' => $row['ip']], '_SELF');
        $member_url = build_url(['page' => 'admin_lookup', 'type' => 'results', 'param' => $row['member_id']], '_SELF');
        $reason = do_lang($row['reason'], $row['reason_param_a'], $row['reason_param_b']);

        $post = with_whitespace(unixify_line_format($row['data_post']));

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

        return do_template('SECURITY_ALERT_SCREEN', [
            '_GUID' => '6c5543151af09c79bf204bea5df61dde',
            'TITLE' => $this->title,
            'USER_AGENT' => $row['user_agent'],
            'REFERER' => $row['referer'],
            'USER_OS' => $row['user_os'],
            'REASON' => $reason,
            'IP' => hyperlink($lookup_url, $row['ip'], false, true),
            'USERNAME' => hyperlink($member_url, $username, false, true),
            'POST' => $post,
            'URL' => $row['url'],
            '_RISK_SCORE' => strval($row['risk_score']),
            'RISK_SCORE' => integer_format($row['risk_score']),
        ]);
    }
}
