<?php /*

 Composr
 Copyright (c) ocProducts, 2004-2016

 See text/EN/licence.txt for full licencing 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  ocProducts Ltd
 * @package    core_forum_drivers
 */

require_code('forum/shared/ipb');

/**
 * Forum driver class.
 *
 * @package    core_forum_drivers
 */
class Forum_driver_ipb3 extends forum_driver_ipb_shared
{
    /**
     * From a member row, get the member's name.
     *
     * @param  array $r The profile-row
     * @return string The member name
     */
    public function mrow_username($r)
    {
        return $this->ipb_unescape($r['name']);
    }

    /**
     * Get a member row for the member of the given name.
     *
     * @param  SHORT_TEXT $name The member name
     * @return ?array The profile-row (null: could not find)
     */
    public function get_mrow($name)
    {
        $rows = $this->connection->query_select('members', array('*'), array('name' => $this->ipb_escape($name)), '', 1);
        if (!array_key_exists(0, $rows)) {
            return null;
        }
        return $rows[0];
    }

    /**
     * Get the name relating to the specified member ID.
     * If this returns null, then the member has been deleted. Always take potential null output into account.
     *
     * @param  MEMBER $member The member ID
     * @return ?SHORT_TEXT The member name (null: member deleted)
     */
    protected function _get_username($member)
    {
        if ($member == $this->get_guest_id()) {
            return do_lang('GUEST');
        }
        return $this->ipb_unescape($this->get_member_row_field($member, 'name'));
    }

    /**
     * Get the display name of a username.
     * If no display name generator is configured, this will be the same as the username.
     *
     * @param  ID_TEXT $username The username
     * @return SHORT_TEXT The display name
     */
    public function get_displayname($username)
    {
        return $this->connection->query_select_value_if_there('members', 'members_display_name', array('name' => $this->ipb_escape($username)));
    }

    /**
     * From a member row, get the member's primary usergroup.
     *
     * @param  array $r The profile-row
     * @return GROUP The member's primary usergroup
     */
    public function mrow_group($r)
    {
        return $r['member_group_id'];
    }

    /**
     * From a member row, get the member's member ID.
     *
     * @param  array $r The profile-row
     * @return MEMBER The member ID
     */
    public function mrow_id($r)
    {
        return $r['member_id'];
    }

    /**
     * Get the rows for the top given number of posters on the forum.
     *
     * @param  integer $limit The limit to the number of top posters to fetch
     * @return array The rows for the given number of top posters in the forum
     */
    public function get_top_posters($limit)
    {
        return $this->connection->query('SELECT * FROM ' . $this->connection->get_table_prefix() . 'members WHERE member_id<>' . strval($this->get_guest_id()) . ' ORDER BY posts DESC', $limit);
    }

    /**
     * This is the opposite of the get_next_member function.
     *
     * @param  MEMBER $member The member ID to decrement
     * @return ?MEMBER The previous member ID (null: no previous member)
     */
    public function get_previous_member($member)
    {
        $tempid = $this->connection->query_value_if_there('SELECT member_id FROM ' . $this->connection->get_table_prefix() . 'members WHERE member_id<' . strval($member) . ' AND member_id<>0 ORDER BY member_id DESC');
        return $tempid;
    }

    /**
     * Get the member ID of the next member after the given one, or null.
     * It cannot be assumed there are no gaps in member IDs, as members may be deleted.
     *
     * @param  MEMBER $member The member ID to increment
     * @return ?MEMBER The next member ID (null: no next member)
     */
    public function get_next_member($member)
    {
        $tempid = $this->connection->query_value_if_there('SELECT member_id FROM ' . $this->connection->get_table_prefix() . 'members WHERE member_id>' . strval($member) . ' ORDER BY member_id');
        return $tempid;
    }

    /**
     * Try to find a member with the given IP address
     *
     * @param  IP $ip The IP address
     * @return array The distinct rows found
     */
    public function probe_ip($ip)
    {
        $a = $this->connection->query_select('members', array('DISTINCT member_id'), array('ip_address' => $ip));
        $b = $this->connection->query_select('posts', array('DISTINCT author_id AS id'), array('ip_address' => $ip));
        return array_merge($a, $b);
    }

    /**
     * Find all members with a name matching the given SQL LIKE string.
     *
     * @param  string $pattern The pattern
     * @param  ?integer $limit Maximum number to return (limits to the most recent active) (null: no limit)
     * @return ?array The array of matched members (null: none found)
     */
    public function get_matching_members($pattern, $limit = null)
    {
        $query = 'SELECT * FROM ' . $this->connection->get_table_prefix() . 'members WHERE name LIKE \'' . db_encode_like($this->ipb_escape($pattern)) . '\' AND member_id<>' . strval($this->get_guest_id()) . ' ORDER BY last_post DESC';
        $rows = $this->connection->query($query, $limit);
        sort_maps_by($rows, 'name');
        return $rows;
    }

    /**
     * Get a member ID from the given member's username.
     *
     * @param  SHORT_TEXT $name The member name
     * @return MEMBER The member ID
     */
    public function get_member_from_username($name)
    {
        if ($name == do_lang('GUEST')) {
            return $this->get_guest_id();
        }

        return $this->connection->query_select_value_if_there('members', 'member_id', array('name' => $this->ipb_escape($name)));
    }

    /**
     * Get a list of custom BBcode tags.
     *
     * @return array The list of tags (each list entry being a map, containing various standard named parameters)
     */
    public function get_custom_bbcode()
    {
        $tags = $this->connection->query_select('custom_bbcode', array('bbcode_replace', 'bbcode_tag'));
        $ret = array();
        foreach ($tags as $tag) {
            $ret[] = array('tag' => $tag['bbcode_tag'], 'replace' => $tag['bbcode_replace'], 'block_tag' => 0, 'textual_tag' => 0, 'dangerous_tag' => 0, 'parameters' => '');
        }
        return $ret;
    }

    /**
     * Add the specified custom field to the forum (some forums implemented this using proper custom profile fields, others through adding a new field).
     *
     * @param  string $name The name of the new custom field
     * @param  integer $length The length of the new custom field
     * @param  BINARY $locked Whether the field is locked
     * @param  BINARY $viewable Whether the field is for viewing
     * @param  BINARY $settable Whether the field is for setting
     * @return boolean Whether the custom field was created successfully
     */
    public function install_create_custom_field($name, $length, $locked = 1, $viewable = 0, $settable = 0)
    {
        $name = 'cms_' . $name;
        $id = $this->connection->query_select_value_if_there('pfields_data', 'pf_id', array('pf_title' => $name));
        if (is_null($id)) {
            $id = $this->connection->query_insert('pfields_data', array('pf_group_id' => 1, 'pf_input_format' => '', 'pf_topic_format' => '{title} : {content}', 'pf_content' => '', 'pf_title' => $name, 'pf_type' => 'text', 'pf_member_hide' => 1 - $viewable, 'pf_max_input' => $length, 'pf_member_edit' => $settable, 'pf_position' => 0), true);
            $this->connection->query('ALTER TABLE ' . $this->connection->get_table_prefix() . 'pfields_content ADD field_' . strval($id) . ' TEXT', null, null, true);
        }
        return !is_null($id);
    }

    /**
     * Edit a custom profile field.
     *
     * @param  string $old_name The name of the current custom field
     * @param  string $new_name The new name of the custom profile field (blank: do not rename)
     * @param  integer $length The new length of the custom field
     * @param  BINARY $locked Whether the field is locked
     * @param  BINARY $viewable Whether the field is for viewing
     * @param  BINARY $settable Whether the field is for setting
     * @return boolean Whether the custom field was edited successfully
     */
    public function install_edit_custom_field($old_name, $new_name, $length, $locked = 1, $viewable = 0, $settable = 0)
    {
        $old_name = 'cms_' . $old_name;
        if ($new_name != '') {
            $new_name = 'cms_' . $new_name;
        } else {
            $new_name = 'cms_' . $old_name;
        }
        $id = $this->connection->query_select_value_if_there('pfields_data', 'pf_id', array('pf_title' => $old_name));
        if (!is_null($id)) {
            $this->connection->query_update('pfields_data', array('pf_title' => $new_name, 'pf_member_hide' => 1 - $viewable, 'pf_max_input' => $length, 'pf_member_edit' => $settable), array('pf_title' => $old_name));
            return true;
        }
        return false;
    }

    /**
     * Set a custom profile field's value, if the custom field exists. Only works on specially-named (titled) fields.
     *
     * @param  MEMBER $member The member ID
     * @param  string $field The field name (e.g. "firstname" for the CPF with a title of "cms_firstname")
     * @param  string $value The value
     */
    public function set_custom_field($member, $field, $value)
    {
        $id = $this->connection->query_select_value_if_there('pfields_data', 'pf_id', array('pf_title' => 'cms_' . $field));
        if (is_null($id)) {
            return;
        }
        $old = $this->connection->query_select_value_if_there('pfields_content', 'member_id', array('member_id' => $member));
        if (is_null($old)) {
            $this->connection->query_insert('pfields_content', array('member_id' => $member));
        }
        $this->connection->query_update('pfields_content', array('field_' . strval($id) => $value), array('member_id' => $member), '', 1);
    }

    /**
     * Get custom profile fields values for all 'cms_' prefixed keys.
     *
     * @param  MEMBER $member The member ID
     * @return ?array A map of the custom profile fields, key_suffix=>value (null: no fields)
     */
    public function get_custom_fields($member)
    {
        $rows = $this->connection->query('SELECT pf_id,pf_title FROM ' . $this->connection->get_table_prefix() . 'pfields_data WHERE pf_title LIKE \'' . db_encode_like('cms\_%') . '\'');
        $values = $this->connection->query_select('pfields_content', array('*'), array('member_id' => $member), '', 1);
        if (!array_key_exists(0, $values)) {
            return null;
        }

        $out = array();
        foreach ($rows as $row) {
            $title = substr($row['pf_title'], 4);
            $out[$title] = $values[0]['field_' . strval($row['pf_id'])];
        }
        return $out;
    }

    /**
     * Searches for forum auto-config at this path.
     *
     * @param  PATH $path The path in which to search
     * @return boolean Whether the forum auto-config could be found
     */
    public function install_test_load_from($path)
    {
        global $PROBED_FORUM_CONFIG;
        if (@file_exists($path . '/conf_global.php')) {
            if (!@file_exists($path . '/conf_shared.php')) { // We can't work with ipb->site bound forums
                @include($path . '/conf_global.php');
                $PROBED_FORUM_CONFIG['cookie_member_id'] = 'member_id';
                $PROBED_FORUM_CONFIG['cookie_member_hash'] = 'pass_hash';
                return true;
            }
        }
        return false;
    }

    /**
     * Get an array of paths to search for config at.
     *
     * @return array The paths in which to search for the forum config
     */
    public function install_get_path_search_list()
    {
        return array(
            0 => 'forums',
            1 => 'forum',
            2 => 'boards',
            3 => 'board',
            4 => 'ipb2',
            5 => 'ipb',
            6 => 'upload',
            7 => 'uploads',
            8 => 'ipboard',
            10 => '../forums',
            11 => '../forum',
            12 => '../boards',
            13 => '../board',
            14 => '../ipb2',
            15 => '../ipb',
            16 => '../upload',
            17 => '../uploads',
            18 => '../ipboard');
    }

    /**
     * Get the avatar URL for the specified member ID.
     *
     * @param  MEMBER $member The member ID
     * @return URLPATH The URL (blank: none)
     */
    public function get_member_avatar_url($member)
    {
        $member_extra_rows = $this->connection->query_select('profile_portal', array('avatar_location', 'avatar_type'), array('pp_member_id' => $member));
        if (!array_key_exists(0, $member_extra_rows)) {
            return '';
        }

        $avatar = $member_extra_rows[0]['avatar_location'];
        if (is_null($avatar)) {
            return '';
        }
        if (substr($avatar, 0, 7) == 'upload:') {
            $avatar = substr($avatar, 7);
        }
        if ($avatar == 'noavatar') {
            $avatar = '';
        }
        if ($avatar == '') {
            return '';
        }
        switch ($member_extra_rows[0]['avatar_type']) {
            case 'gravatar':
                $gravatar_img = md5(strtolower(trim($avatar)));
                $avatar = 'http://www.gravatar.com/avatar/' . $gravatar_img;
                break;
            case 'local':
                $avatar = get_forum_base_url() . '/style_avatars/' . $avatar;
                break;
            case 'upload':
                $avatar = get_forum_base_url() . '/uploads/' . $avatar;
                break;
            default:
                if (url_is_local($avatar)) {
                    $avatar = get_forum_base_url() . '/uploads/' . $avatar;
                }
                break;
        }

        return $avatar;
    }

    /**
     * Get the photo thumbnail URL for the specified member ID.
     *
     * @param  MEMBER $member The member ID
     * @return URLPATH The URL (blank: none)
     */
    public function get_member_photo_url($member)
    {
        $pic = $this->connection->query_select_value_if_there('profile_portal', 'pp_main_photo', array('pp_member_id' => $member));
        if (is_null($pic)) {
            $pic = '';
        } elseif ((url_is_local($pic)) && ($pic != '')) {
            $pic = get_forum_base_url() . '/uploads/' . $pic;
        }

        return $pic;
    }

    /**
     * Makes a post in the specified forum, in the specified topic according to the given specifications. If the topic doesn't exist, it is created along with a spacer-post.
     * Spacer posts exist in order to allow staff to delete the first true post in a topic. Without spacers, this would not be possible with most forum systems. They also serve to provide meta information on the topic that cannot be encoded in the title (such as a link to the content being commented upon).
     *
     * @param  SHORT_TEXT $forum_name The forum name
     * @param  SHORT_TEXT $topic_identifier The topic identifier (usually <content-type>_<content-id>)
     * @param  MEMBER $member The member ID
     * @param  LONG_TEXT $post_title The post title
     * @param  LONG_TEXT $_post The post content in Comcode format
     * @param  string $content_title The topic title; must be same as content title if this is for a comment topic
     * @param  string $topic_identifier_encapsulation_prefix This is put together with the topic identifier to make a more-human-readable topic title or topic description (hopefully the latter and a $content_title title, but only if the forum supports descriptions)
     * @param  ?URLPATH $content_url URL to the content (null: do not make spacer post)
     * @param  ?TIME $time The post time (null: use current time)
     * @param  ?IP $ip The post IP address (null: use current members IP address)
     * @param  ?BINARY $validated Whether the post is validated (null: unknown, find whether it needs to be marked unvalidated initially). This only works with the Conversr driver.
     * @param  ?BINARY $topic_validated Whether the topic is validated (null: unknown, find whether it needs to be marked unvalidated initially). This only works with the Conversr driver.
     * @param  boolean $skip_post_checks Whether to skip post checks
     * @param  SHORT_TEXT $poster_name_if_guest The name of the poster
     * @param  ?AUTO_LINK $parent_id ID of post being replied to (null: N/A)
     * @param  boolean $staff_only Whether the reply is only visible to staff
     * @return array Topic ID (may be null), and whether a hidden post has been made
     */
    public function make_post_forum_topic($forum_name, $topic_identifier, $member, $post_title, $_post, $content_title, $topic_identifier_encapsulation_prefix, $content_url = null, $time = null, $ip = null, $validated = null, $topic_validated = 1, $skip_post_checks = false, $poster_name_if_guest = '', $parent_id = null, $staff_only = false)
    {
        $__post = comcode_to_tempcode($_post);
        $post = $__post->evaluate();

        if (is_null($time)) {
            $time = time();
        }
        if (is_null($ip)) {
            $ip = get_ip_address();
        }
        $forum_id = $this->forum_id_from_name($forum_name);
        if (is_null($forum_id)) {
            warn_exit(do_lang_tempcode('MISSING_FORUM', escape_html($forum_name)));
        }
        $username = $this->get_username($member);

        $topic_id = $this->find_topic_id_for_topic_identifier($forum_name, $topic_identifier);

        $is_new = is_null($topic_id);
        if ($is_new) {
            $topic_id = $this->connection->query_insert('topics', array('moved_to' => 0, 'pinned' => 0, 'views' => 0, 'description' => $topic_identifier_encapsulation_prefix . ': #' . $topic_identifier, 'title' => $this->ipb_escape($content_title), 'state' => 'open', 'posts' => 1, 'starter_id' => $member, 'start_date' => $time, 'icon_id' => 0, 'starter_name' => $this->ipb_escape($username), 'poll_state' => 0, 'last_vote' => 0, 'forum_id' => $forum_id, 'approved' => 1, 'author_mode' => 1), true);
            $home_link = hyperlink($content_url, $content_title, false, true);
            $this->connection->query_insert('posts', array('author_id' => $member, 'author_name' => $this->ipb_escape($username), 'ip_address' => '127.0.0.1', 'post_date' => $time, 'icon_id' => 0, 'post' => do_lang('SPACER_POST', $home_link->evaluate(), '', '', get_site_default_lang()), 'queued' => 0, 'topic_id' => $topic_id, 'new_topic' => 1, 'post_htmlstate' => 1, 'post_title' => $post_title, 'post_key' => md5(microtime(false))));
            $this->connection->query('UPDATE ' . $this->connection->get_table_prefix() . 'forums SET topics=(topics+1) WHERE id=' . strval($forum_id), 1);
            $first_post = true;
        } else {
            $first_post = false;
        }

        $GLOBALS['LAST_TOPIC_ID'] = $topic_id;
        $GLOBALS['LAST_TOPIC_IS_NEW'] = $is_new;

        if ($post == '') {
            return array($topic_id, false);
        }

        $post_id = $this->connection->query_insert('posts', array('author_id' => $member, 'author_name' => $this->ipb_escape($username), 'ip_address' => $ip, 'post_date' => $time, 'icon_id' => 0, 'post' => $post, 'queued' => 0, 'topic_id' => $topic_id, 'new_topic' => 1, 'post_htmlstate' => 1, 'post_title' => $post_title, 'post_key' => md5(microtime(false))), true);
        $test = $this->connection->query_select('forums', array('*'), null, '', 1);
        if (array_key_exists('newest_title', $test[0])) {
            $this->connection->query('UPDATE ' . $this->connection->get_table_prefix() . 'forums SET posts=(posts+1), last_post=' . strval($time) . ', last_poster_id=' . strval($member) . ', last_poster_name=\'' . db_escape_string($this->ipb_escape($username)) . '\', newest_id=' . strval($topic_id) . ', newest_title=\'' . db_escape_string($this->ipb_escape($post_title)) . '\', last_id=' . strval($topic_id) . ', last_title=\'' . db_escape_string($this->ipb_escape($post_title)) . '\' WHERE id=' . strval($forum_id), 1);
        } else {
            $this->connection->query('UPDATE ' . $this->connection->get_table_prefix() . 'forums SET posts=(posts+1), last_post=' . strval($time) . ', last_poster_id=' . strval($member) . ', last_poster_name=\'' . db_escape_string($this->ipb_escape($username)) . '\', last_id=' . strval($topic_id) . ', last_title=\'' . db_escape_string($this->ipb_escape($post_title)) . '\' WHERE id=' . strval($forum_id), 1);
        }
        if ($first_post) {
            $this->connection->query('UPDATE ' . $this->connection->get_table_prefix() . 'topics SET topic_firstpost=' . strval($post_id) . ', posts=(posts+1), last_post=' . strval($time) . ', last_poster_id=' . strval($member) . ', last_poster_name=\'' . db_escape_string($this->ipb_escape($username)) . '\' WHERE tid=' . strval($topic_id), 1);
        } else {
            $this->connection->query('UPDATE ' . $this->connection->get_table_prefix() . 'topics SET posts=(posts+1), last_post=' . strval($time) . ', last_poster_id=' . strval($member) . ', last_poster_name=\'' . db_escape_string($this->ipb_escape($username)) . '\' WHERE tid=' . strval($topic_id), 1);
        }

        return array($topic_id, false);
    }

    /**
     * Get an array of maps for the topic in the given forum.
     *
     * @param  integer $topic_id The topic ID
     * @param  integer $count The comment count will be returned here by reference
     * @param  integer $max Maximum comments to returned
     * @param  integer $start Comment to start at
     * @param  boolean $mark_read Whether to mark the topic read (ignored for this forum driver)
     * @param  boolean $reverse Whether to show in reverse
     * @return mixed The array of maps (Each map is: title, message, member, date) (-1 for no such forum, -2 for no such topic)
     */
    public function get_forum_topic_posts($topic_id, &$count, $max = 100, $start = 0, $mark_read = true, $reverse = false)
    {
        if (is_null($topic_id)) {
            return (-2);
        }
        $order = $reverse ? 'post_date DESC' : 'post_date';
        $rows = $this->connection->query('SELECT * FROM ' . $this->connection->get_table_prefix() . 'posts WHERE topic_id=' . strval($topic_id) . ' AND post NOT LIKE \'' . db_encode_like(substr(do_lang('SPACER_POST', '', '', '', get_site_default_lang()), 0, 20) . '%') . '\' ORDER BY ' . $order, $max, $start);
        $count = $this->connection->query_value_if_there('SELECT COUNT(*) FROM ' . $this->connection->get_table_prefix() . 'posts WHERE topic_id=' . strval($topic_id) . ' AND post NOT LIKE \'' . db_encode_like(substr(do_lang('SPACER_POST', '', '', '', get_site_default_lang()), 0, 20) . '%') . '\'');
        $out = array();
        $emoticons_set_dir = $this->get_emo_dir();
        foreach ($rows as $myrow) {
            $temp = array();

            $temp['title'] = $this->ipb_unescape($myrow['post_title']);
            if (is_null($temp['title'])) {
                $temp['title'] = '';
            }
            $post = preg_replace('#public/style_emoticons/<\#EMO_DIR\#>(.+?)\'#is', $emoticons_set_dir . '\\1\'', $myrow['post']);
            $temp['message'] = $post;
            $temp['member'] = $myrow['author_id'];
            $temp['date'] = $myrow['post_date'];

            $out[] = $temp;
        }

        return $out;
    }

    /**
     * Get an array of topics in the given forum. Each topic is an array with the following attributes:
     * - id, the topic ID
     * - title, the topic title
     * - lastusername, the username of the last poster
     * - lasttime, the timestamp of the last reply
     * - closed, a Boolean for whether the topic is currently closed or not
     * - firsttitle, the title of the first post
     * - firstpost, the first post (only set if $show_first_posts was true)
     *
     * @param  mixed $name The forum name or an array of forum IDs
     * @param  integer $limit The limit
     * @param  integer $start The start position
     * @param  integer $max_rows The total rows (not a parameter: returns by reference)
     * @param  SHORT_TEXT $filter_topic_title The topic title filter
     * @param  boolean $show_first_posts Whether to show the first posts
     * @param  string $date_key The date key to sort by
     * @set    lasttime firsttime
     * @param  boolean $hot Whether to limit to hot topics
     * @param  SHORT_TEXT $filter_topic_description The topic description filter
     * @return ?array The array of topics (null: error)
     */
    public function show_forum_topics($name, $limit, $start, &$max_rows, $filter_topic_title = '', $show_first_posts = false, $date_key = 'lasttime', $hot = false, $filter_topic_description = '')
    {
        require_code('xhtml');

        if (is_integer($name)) {
            $id_list = 'forum_id=' . strval($name);
        } elseif (!is_array($name)) {
            if (($name == '<announce>') || (is_null($name))) {
                $id_list = '(forum_id IS NULL)';
            } else {
                $id = $this->forum_id_from_name($name);
                if (is_null($id)) {
                    return null;
                }
                $id_list = 'forum_id=' . strval($id);
            }
        } else {
            $id_list = '';
            $id_list_2 = '';
            foreach (array_keys($name) as $id) {
                if ($id_list != '') {
                    $id_list .= ' OR ';
                }
                if ((is_null($id)) || ($id == '')) {
                    $id_list .= '(forum_id IS NULL)';
                } else {
                    $id_list .= 'forum_id=' . strval($id);
                }
            }
            if ($id_list == '') {
                return null;
            }
        }

        $topic_filter = ($filter_topic_title != '') ? 'AND title LIKE \'' . db_encode_like($this->ipb_escape($filter_topic_title)) . '\'' : '';
        if ($filter_topic_description != '') {
            $topic_filter .= ' AND description LIKE \'' . db_encode_like($this->ipb_escape($filter_topic_description)) . '\'';
        }
        $rows = $this->connection->query('SELECT * FROM ' . $this->connection->get_table_prefix() . 'topics WHERE (' . $id_list . ') ' . $topic_filter . ' ORDER BY ' . (($date_key == 'lasttime') ? 'last_post' : 'start_date') . ' DESC', $limit, $start);
        $max_rows = $this->connection->query_value_if_there('SELECT COUNT(*) FROM ' . $this->connection->get_table_prefix() . 'topics WHERE (' . $id_list . ') ' . $topic_filter);
        $emoticons_set_dir = $this->get_emo_dir();
        $out = array();
        foreach ($rows as $i => $r) {
            $out[$i] = array();
            $out[$i]['id'] = $r['tid'];
            $out[$i]['num'] = $r['posts'];
            $out[$i]['title'] = $this->ipb_unescape($r['title']);
            $out[$i]['firstusername'] = $this->ipb_unescape($r['starter_name']);
            $out[$i]['lastusername'] = $this->ipb_unescape($r['last_poster_name']);
            $out[$i]['firstmemberid'] = $r['starter_id'];
            $out[$i]['lastmemberid'] = $r['last_poster_id'];
            $out[$i]['firsttime'] = $r['start_date'];
            $out[$i]['lasttime'] = $r['last_post'];
            $out[$i]['closed'] = ($r['state'] == 'closed');
            $fp_rows = $this->connection->query('SELECT * FROM ' . $this->connection->get_table_prefix() . 'posts WHERE post NOT LIKE \'' . db_encode_like(do_lang('SPACER_POST', '', '', '', get_site_default_lang()) . '%') . '\' AND topic_id=' . strval($out[$i]['id']) . ' ORDER BY post_date', 1);
            if (!array_key_exists(0, $fp_rows)) {
                unset($out[$i]);
                continue;
            }
            $out[$i]['firsttitle'] = $this->ipb_unescape($fp_rows[0]['post_title']);
            if ($show_first_posts) {
                $post_id = $fp_rows[0]['pid'];
                $post = $fp_rows[0]['post'];
                if ((array_key_exists('post_htmlstate', $fp_rows[0])) && ($fp_rows[0]['post_htmlstate'] != 0)) {
                    if ($fp_rows[0]['post_htmlstate'] == 1) {
                        $post = str_replace('<br />', '', $post);
                    }
                    $post = @html_entity_decode($post, ENT_QUOTES, get_charset());
                }
                $post = preg_replace('#public/style_emoticons/<\#EMO_DIR\#>(.+?)\'#is', $emoticons_set_dir . '\\1\'', $post);

                $post = str_replace("class='quotetop'", "class='comcode_quote'", $post);
                $post = str_replace("class='quotemain'", "class='comcode_quote_inner'", $post);

                // Attachments
                $attachments = $this->connection->query_select('attachments', array('attach_member_id', 'attach_id', 'attach_file', 'attach_location', 'attach_thumb_location', 'attach_is_image', 'attach_filesize', 'attach_hits'), array('attach_post_key' => $fp_rows[0]['post_key']/*, 'attach_approved' => true Gone in IPB3? */));
                foreach ($attachments as $attachment) {
                    if (($attachment['attach_thumb_location'] != '') || ($attachment['attach_is_image'] == 0)) { // Not fully inline
                        $url = get_forum_base_url() . '/index.php?act=Attach&type=post&id=' . urlencode($attachment['attach_id']);
                        if ($attachment['attach_thumb_location'] != '') {
                            $special = do_template('FORUM_ATTACHMENT_IMAGE_THUMB', array('_GUID' => 'f06760e3325efd9be27e2d5c89611d43', 'FULL' => $url, 'URL' => get_forum_base_url() . '/uploads/' . $attachment['attach_thumb_location']));
                        } else {
                            require_code('files');
                            $special = do_template('FORUM_ATTACHMENT_LINK', array('_GUID' => '344b0b127433938302118b2ca7572452', 'FULL' => $url, 'FILENAME' => $attachment['attach_file'], 'CLEAN_SIZE' => clean_file_size($attachment['attach_filesize']), 'NUM_DOWNLOADS' => integer_format($attachment['attach_hits'])));
                        }
                    } else { // Must be an inline image
                        $special = do_template('FORUM_ATTACHMENT_IMAGE', array('_GUID' => '22f1e3e5a9e156afc53d847cabe4a754', 'URL' => get_forum_base_url() . '/uploads/' . $attachment['attach_location']));
                    }

                    // See if we have to place it somewhere special inside the post
                    $old_post = $post;
                    $post = str_replace('[attachmentid=' . $attachment['attach_id'] . ']', $special->evaluate(), $post);
                    if ($old_post == $post) {
                        $post .= $special->evaluate();
                    }
                }

                global $LAX_COMCODE;
                $end = 0;
                while (($pos = strpos($post, '[right]', $end)) !== false) {
                    $e_pos = strpos($post, '[/right]', $pos);
                    if ($e_pos === false) {
                        break;
                    }
                    $end = $e_pos + strlen('[/right]');
                    $segment = substr($post, $pos, $end - $pos);
                    $temp = $LAX_COMCODE;
                    $LAX_COMCODE = true;
                    $comcode = comcode_to_tempcode($segment, $r['starter_id']);
                    $LAX_COMCODE = $temp;
                    $post = substr($post, 0, $pos) . $comcode->evaluate() . substr($post, $end);
                }
                $temp = $LAX_COMCODE;
                $LAX_COMCODE = true;
                $out[$i]['firstpost'] = comcode_to_tempcode(xhtmlise_html($post), $r['starter_id'], false, null, null, null, false, false, true); // Assumes HTML for posts
                $LAX_COMCODE = $temp;
            }
        }
        if (count($out) != 0) {
            return $out;
        }
        return null;
    }

    /**
     * Find the base URL to the emoticons.
     *
     * @return URLPATH The base URL
     */
    public function get_emo_dir()
    {
        global $EMOTICON_SET_DIR;
        if (is_null($EMOTICON_SET_DIR)) {
            $EMOTICON_SET_DIR = $this->connection->query_select_value_if_there('skin_collections', 'set_emo_dir', array('set_image_dir' => $this->get_theme()));
            if (is_null($EMOTICON_SET_DIR)) {
                $EMOTICON_SET_DIR = 'default';
            }
        }
        return get_forum_base_url() . '/public/style_emoticons/' . $EMOTICON_SET_DIR . '/';
    }

    /**
     * Get a map between emoticon codes and templates representing the HTML-image-code for this emoticon. The emoticons presented of course depend on the forum involved.
     *
     * @return array The map
     */
    public function find_emoticons()
    {
        if (!is_null($this->EMOTICON_CACHE)) {
            return $this->EMOTICON_CACHE;
        }
        $rows = $this->connection->query_select('emoticons', array('*'));
        if (!is_array($rows)) {
            return array(); // weird importer trouble
        }
        $this->EMOTICON_CACHE = array();
        foreach ($rows as $myrow) {
            if (strlen($myrow['image']) > 0) {
                $src = $myrow['image'];
                if (url_is_local($src)) {
                    $src = $this->get_emo_dir() . $src;
                }
                $this->EMOTICON_CACHE[$this->ipb_unescape($myrow['typed'])] = array('EMOTICON_IMG_CODE_DIR', $src, $myrow['typed']);
            }
        }
        uksort($this->EMOTICON_CACHE, '_strlen_sort');
        $this->EMOTICON_CACHE = array_reverse($this->EMOTICON_CACHE);
        return $this->EMOTICON_CACHE;
    }

    /**
     * Find a list of all forum skins (aka themes).
     *
     * @return array The list of skins
     */
    public function get_skin_list()
    {
        $table = 'skin_collections';
        $codename = 'set_image_dir';

        $rows = $this->connection->query_select($table, array($codename));
        return collapse_1d_complexity($codename, $rows);
    }

    /**
     * Try to find the theme that the logged-in/guest member is using, and map it to a Composr theme.
     * The themes/map.ini file functions to provide this mapping between forum themes, and Composr themes, and has a slightly different meaning for different forum drivers. For example, some drivers map the forum themes theme directory to the Composr theme name, while others made the humanly readeable name.
     *
     * @param  boolean $skip_member_specific Whether to avoid member-specific lookup (i.e. find via what forum theme is currently configured as the default)
     * @param  ?MEMBER $member The member to find for (null: current member)
     * @return ID_TEXT The theme
     */
    public function _get_theme($skip_member_specific = false, $member = null)
    {
        $def = '';

        // Load in remapper
        require_code('files');
        $map = file_exists(get_file_base() . '/themes/map.ini') ? better_parse_ini_file(get_file_base() . '/themes/map.ini') : array();

        if (!$skip_member_specific) {
            // Work out
            if ($member === null) {
                $member = get_member();
            }
            if ($member > 0) {
                $skin = $this->get_member_row_field($member, 'skin');
            } else {
                $skin = 0;
            }
            if ($skin > 0) { // User has a custom theme
                $ipb = $this->connection->query_select_value_if_there('skin_collections', 'set_image_dir', array('set_id' => $skin));
                if (!is_null($ipb)) {
                    $def = array_key_exists($ipb, $map) ? $map[$ipb] : $ipb;
                }
            }
        }

        // Look for a skin according to our site name (we bother with this instead of 'default' because Composr itself likes to never choose a theme when forum-theme integration is on: all forum [via map] or all Composr seems cleaner, although it is complex)
        if ((!(strlen($def) > 0)) || (!file_exists(get_custom_file_base() . '/themes/' . $def))) {
            $ipb = $this->connection->query_select_value_if_there('skin_collections', 'set_image_dir', array('set_name' => get_site_name()));
            if (!is_null($ipb)) {
                $def = array_key_exists($ipb, $map) ? $map[$ipb] : $ipb;
            }
        }

        // Hmm, just the very-default then
        if ((!(strlen($def) > 0)) || (!file_exists(get_custom_file_base() . '/themes/' . $def))) {
            $ipb = $this->connection->query_select_value_if_there('skin_collections', 'set_image_dir', array('set_is_default' => 1));
            if (!is_null($ipb)) {
                $def = array_key_exists($ipb, $map) ? $map[$ipb] : $ipb;
            }
        }

        // Default then!
        if ((!(strlen($def) > 0)) || (!file_exists(get_custom_file_base() . '/themes/' . $def))) {
            $def = array_key_exists('default', $map) ? $map['default'] : 'default';
        }

        return $def;
    }

    /**
     * Get a URL to the registration page (for people to create member accounts).
     *
     * @return URLPATH The URL to the registration page
     */
    protected function _join_url()
    {
        return get_forum_base_url() . '/index.php?app=core&module=global&section=register';
    }

    /**
     * Get a URL to the specified member's home (control panel).
     *
     * @param  MEMBER $id The member ID
     * @return URLPATH The URL to the members home
     */
    public function member_home_url($id)
    {
        return get_forum_base_url() . '/index.php?app=core&module=usercp';
    }

    /**
     * Get a URL to the members-online page.
     *
     * @return URLPATH The URL to the members-online page
     */
    protected function _users_online_url()
    {
        return get_forum_base_url() . '/index.php?app=members&module=online&sort_order=desc';
    }

    /**
     * Get a URL to send a private/personal message to the given member.
     *
     * @param  MEMBER $id The member ID
     * @return URLPATH The URL to the private/personal message page
     */
    protected function _member_pm_url($id)
    {
        return get_forum_base_url() . '/index.php?app=members&module=messaging&section=send&do=form&fromMemberID=' . strval($id);
    }

    /**
     * Get the number of members registered on the forum.
     *
     * @return integer The number of members
     */
    public function get_members()
    {
        $r = $this->connection->query('SELECT COUNT(*) AS a FROM ' . $this->connection->get_table_prefix() . 'members WHERE member_group_id<>1');
        return $r[0]['a'];
    }

    /**
     * Get the total topics ever made on the forum.
     *
     * @return integer The number of topics
     */
    public function get_topics()
    {
        return $this->connection->query_select_value('topics', 'COUNT(*)');
    }

    /**
     * Get the total posts ever made on the forum.
     *
     * @return integer The number of posts
     */
    public function get_num_forum_posts()
    {
        return $this->connection->query_select_value('posts', 'COUNT(*)');
    }

    /**
     * Get the forum usergroup relating to the specified member ID.
     *
     * @param  MEMBER $member The member ID
     * @return array The array of forum usergroups
     */
    protected function _get_members_groups($member)
    {
        $group = $this->get_member_row_field($member, 'member_group_id');
        $secondary = array($group);
        $more = $this->get_member_row_field($member, 'mgroup_others');
        if (($more != '') && (!is_null($more))) {
            $secondary = array_merge($secondary, explode(',', $more));
        }
        return $secondary;
    }

    /**
     * Get an array of members who are in at least one of the given array of usergroups.
     *
     * @param  array $groups The array of usergroups
     * @param  ?integer $max Return up to this many entries for primary members and this many entries for secondary members (null: no limit, only use no limit if querying very restricted usergroups!)
     * @param  integer $start Return primary members after this offset and secondary members after this offset
     * @return ?array The array of members (null: no members)
     */
    public function member_group_query($groups, $max = null, $start = 0)
    {
        $_groups = '';
        foreach ($groups as $group) {
            if ($_groups != '') {
                $_groups .= ' OR ';
            }
            $_groups .= 'member_group_id=' . strval($group);
        }
        return $this->connection->query('SELECT * FROM ' . $this->connection->get_table_prefix() . 'members WHERE ' . $_groups . ' ORDER BY member_group_id,member_id ASC', $max, $start);
    }

    /**
     * Find if the specified member ID is marked as staff or not.
     *
     * @param  MEMBER $member The member ID
     * @return boolean Whether the member is staff
     */
    protected function _is_staff($member)
    {
        $usergroup = $this->get_member_row_field($member, 'member_group_id');
        if ((!is_null($usergroup)) && ($this->connection->query_select_value_if_there('groups', 'g_is_supmod', array('g_id' => $usergroup)) == 1)) {
            return true;
        }
        return false;
    }

    /**
     * Find if the specified member ID is marked as a super admin or not.
     *
     * @param  MEMBER $member The member ID
     * @return boolean Whether the member is a super admin
     */
    protected function _is_super_admin($member)
    {
        $usergroup = $this->get_member_row_field($member, 'member_group_id');
        if ((!is_null($usergroup)) && ($this->connection->query_select_value_if_there('groups', 'g_access_cp', array('g_id' => $usergroup)) == 1)) {
            return true;
        }
        return false;
    }

    /**
     * Create a member login cookie.
     *
     * @param  MEMBER $id The member ID
     * @param  ?SHORT_TEXT $name The username (null: lookup)
     * @param  string $password The password
     */
    public function forum_create_cookie($id, $name, $password)
    {
        // User
        cms_setcookie(get_member_cookie(), strval($id));

        // Password
        $_password = $this->get_member_row_field($id, 'member_login_key');
        cms_setcookie(get_pass_cookie(), $_password);

        // Set stronghold
        global $SITE_INFO;
        if ((!empty($SITE_INFO['stronghold_cookies'])) && ($SITE_INFO['stronghold_cookies'] == '1')) {
            $ip_octets = explode('.', cms_srv('REMOTE_ADDR'));
            $crypt_salt = md5(get_db_forums_password() . get_db_forums_user());
            $a = get_member_cookie();
            $b = get_pass_cookie();
            for ($i = 0; $i < strlen($a) && $i < strlen($b); $i++) {
                if ($a[$i] != $b[$i]) {
                    break;
                }
            }
            $cookie_prefix = substr($a, 0, $i);
            $stronghold = md5(md5(strval($id) . '-' . $ip_octets[0] . '-' . $ip_octets[1] . '-' . $_password) . $crypt_salt);
            cms_setcookie($cookie_prefix . 'ipb_stronghold', $stronghold);
        }
    }

    /**
     * Find out if the given member ID is banned.
     *
     * @param  MEMBER $member The member ID
     * @return boolean Whether the member is banned
     */
    public function is_banned($member)
    {
        // Are they banned
        $banned = $this->connection->query_select_value_if_there('members', 'member_banned', array('member_id' => $member));
        if (is_null($banned)) {
            return false;
        }
        return $banned == 1;
    }

    /**
     * Find if the given member ID and password is valid. If username is null, then the member ID is used instead.
     * All authorisation, cookies, and form-logins, are passed through this function.
     * Some forums do cookie logins differently, so a Boolean is passed in to indicate whether it is a cookie login.
     *
     * @param  ?SHORT_TEXT $username The member username (null: don't use this in the authentication - but look it up using the ID if needed)
     * @param  MEMBER $userid The member ID
     * @param  SHORT_TEXT $password_hashed The md5-hashed password
     * @param  string $password_raw The raw password
     * @param  boolean $cookie_login Whether this is a cookie login
     * @return array A map of 'id' and 'error'. If 'id' is null, an error occurred and 'error' is set
     */
    public function forum_authorise_login($username, $userid, $password_hashed, $password_raw, $cookie_login = false)
    {
        $out = array();
        $out['id'] = null;

        if (is_null($userid)) {
            $rows = $this->connection->query_select('members', array('*'), array('name' => $username), '', 1);
            if (array_key_exists(0, $rows)) {
                $this->MEMBER_ROWS_CACHED[$rows[0]['member_id']] = $rows[0];
            }
        } else {
            $rows = array();
            $rows[0] = $this->get_member_row($userid);
        }

        if (!array_key_exists(0, $rows) || $rows[0] === null) { // All hands to lifeboats
            $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : '_MEMBER_NO_EXIST', $username);
            return $out;
        }
        $row = $rows[0];
        if ($row['member_banned'] == 1) { // All hands to the guns
            $out['error'] = do_lang_tempcode('YOU_ARE_BANNED');
            return $out;
        }
        if ($cookie_login) {
            if ($password_hashed != $row['member_login_key']) {
                $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
                return $out;
            }

            // Check stronghold
            global $SITE_INFO;
            if ((!empty($SITE_INFO['stronghold_cookies'])) && ($SITE_INFO['stronghold_cookies'] == '1')) {
                $ip_octets = explode('.', cms_srv('REMOTE_ADDR'));
                $crypt_salt = md5(get_db_forums_password() . get_db_forums_user());
                $a = get_member_cookie();
                $b = get_pass_cookie();
                for ($i = 0; $i < strlen($a) && $i < strlen($b); $i++) {
                    if ($a[$i] != $b[$i]) {
                        break;
                    }
                }
                $cookie_prefix = substr($a, 0, $i);
                $cookie = cms_admirecookie($cookie_prefix . 'ipb_stronghold');
                $stronghold = md5(md5(strval($row['member_id']) . '-' . $ip_octets[0] . '-' . $ip_octets[1] . '-' . $row['member_login_key']) . $crypt_salt);
                if ($cookie != $stronghold) {
                    $out['error'] = do_lang_tempcode('MEMBER_BAD_STRONGHOLD');
                    return $out;
                }
            }
        } else {
            if (!$this->_auth_hashed($row['member_id'], $password_hashed)) {
                $out['error'] = do_lang_tempcode((get_option('login_error_secrecy') == '1') ? 'MEMBER_INVALID_LOGIN' : 'MEMBER_BAD_PASSWORD');
                return $out;
            }
        }

        if (substr(get_member_cookie(), 0, 5) != 'cms__') {
            $pos = strpos(get_member_cookie(), 'member_id');
            require_code('users_active_actions');
            cms_eatcookie(substr(get_member_cookie(), 0, $pos) . 'session_id');
        }

        $out['id'] = $row['member_id'];
        return $out;
    }

    /**
     * Do converge authentication.
     *
     * @param  MEMBER $id The member ID
     * @param  string $password The password
     * @return boolean Whether authentication succeeded
     */
    protected function _auth_hashed($id, $password)
    {
        $rows = $this->connection->query_select('members', array('members_pass_hash', 'members_pass_salt'), array('member_id' => $id), '', 1);
        if (!array_key_exists(0, $rows)) {
            return false;
        }
        $row = $rows[0];
        if (md5(md5($row['members_pass_salt']) . $password) != $row['members_pass_hash']) {
            return false;
        }
        return true;
    }

    /**
     * Gets a whole member row from the database.
     *
     * @param  MEMBER $member The member ID
     * @return ?array The member row (null: no such member)
     */
    public function get_member_row($member)
    {
        if (array_key_exists($member, $this->MEMBER_ROWS_CACHED)) {
            return $this->MEMBER_ROWS_CACHED[$member];
        }

        if ($member == 0) {
            $rows = array();
            $rows[0] = array();
            $rows[0]['member_id'] = 0;
            $rows[0]['name'] = $this->ipb_escape(do_lang('GUEST'));
            $rows[0]['members_display_name'] = $this->ipb_escape(do_lang('GUEST'));
            $rows[0]['member_group_id'] = 2;
            $rows[0]['language'] = null;
        } else {
            $rows = $this->connection->query_select('members', array('*'), array('member_id' => $member), '', 1);
        }

        $this->MEMBER_ROWS_CACHED[$member] = array_key_exists(0, $rows) ? $rows[0] : null;
        return $this->MEMBER_ROWS_CACHED[$member];
    }
}
