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

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__permissions()
{
    global $SPAM_REMOVE_VALIDATION, $USERSUBMITBAN_MEMBER_CACHE;

    $SPAM_REMOVE_VALIDATION = false;
    $USERSUBMITBAN_MEMBER_CACHE = null;

    clear_permissions_runtime_cache();

    if (!defined('COMCODE_EDIT_NONE')) {
        define('COMCODE_EDIT_NONE', 0x0);
        define('COMCODE_EDIT_OWN', 0x1);
        define('COMCODE_EDIT_ANY', 0x2);
    }
}

/**
 * Clear permissions API run-time caching.
 */
function clear_permissions_runtime_cache()
{
    global $PRIVILEGE_CACHE, $GROUP_PRIVILEGE_CACHE, $ZONE_ACCESS_CACHE, $PAGE_ACCESS_CACHE, $PAGE_ACCESS_CACHE_MATCH_KEYS, $CATEGORY_ACCESS_CACHE, $LOADED_ALL_CATEGORY_PERMISSIONS_FOR_CACHE, $SUBMIT_PERMISSION_CACHE, $PERMISSION_CHECK_LOGGER, $PERMISSIONS_ALREADY_LOGGED;

    $PRIVILEGE_CACHE = [];

    $GROUP_PRIVILEGE_CACHE = [];

    $ZONE_ACCESS_CACHE = [];

    $PAGE_ACCESS_CACHE = [];
    $PAGE_ACCESS_CACHE_MATCH_KEYS = [];

    $CATEGORY_ACCESS_CACHE = [];
    $LOADED_ALL_CATEGORY_PERMISSIONS_FOR_CACHE = [];

    $SUBMIT_PERMISSION_CACHE = [];

    $PERMISSION_CHECK_LOGGER = null;
    $PERMISSIONS_ALREADY_LOGGED = [];
}

/**
 * Log permission checks to the permission_checks.log file, if it exists.
 *
 * @param  MEMBER $member_id The user checking against
 * @param  ID_TEXT $op The function that was called to check a permission
 * @param  array $params Parameters to this permission-checking function
 * @param  boolean $result Whether the permission was held
 */
function handle_permission_check_logging(int $member_id, string $op, array $params, bool $result)
{
    global $PERMISSION_CHECK_LOGGER, $PERMISSIONS_ALREADY_LOGGED, $SITE_INFO;
    if ($PERMISSION_CHECK_LOGGER === null) {
        $file_path = get_custom_file_base() . '/data_custom/permission_checks.log';
        if (((!isset($SITE_INFO['no_extra_logs'])) || ($SITE_INFO['no_extra_logs'] != '1')) && (is_file($file_path)) && (cms_is_writable($file_path))) {
            require_code('files');
            $PERMISSION_CHECK_LOGGER = cms_fopen_text_write($file_path, false, 'ab');
            if (!function_exists('get_self_url')) {
                require_code('tempcode');
                require_code('urls');
            }
            $self_url = get_self_url(true);
            if (!is_string($self_url)) {
                $self_url = get_self_url_easy(true); // A weirdness can happen here. If some kind of fatal error happens then output buffers can malfunction making it impossible to use Tempcode as above. So we fall back to this. (This function may be called in a fatal error).
            }
            flock($PERMISSION_CHECK_LOGGER, LOCK_EX);
            fseek($PERMISSION_CHECK_LOGGER, 0, SEEK_END);
            fwrite($PERMISSION_CHECK_LOGGER, "\n\n" . loggable_date() . ' -- ' . $self_url . ' -- ' . $GLOBALS['FORUM_DRIVER']->get_username(get_member()) . "\n");
            flock($PERMISSION_CHECK_LOGGER, LOCK_UN);
        } else {
            $PERMISSION_CHECK_LOGGER = false;
        }
    }
    static $fbe = null;
    if ($fbe === null) {
        $fbe = function_exists('fb');
    }
    if (($PERMISSION_CHECK_LOGGER === false) && ((!$fbe) || (get_param_integer('keep_firephp', 0) == 0))) {
        return;
    }
    $sz = serialize([$member_id, $op, $params]);
    if (array_key_exists($sz, $PERMISSIONS_ALREADY_LOGGED)) {
        return;
    }
    $PERMISSIONS_ALREADY_LOGGED[$sz] = true;
    if ($result) {
        return;
    }
    require_code('permissions2');
    _handle_permission_check_logging($member_id, $op, $params, $result);
}

/**
 * Show a helpful access-denied page. Has a login ability if it senses that logging in could curtail the error.
 * Note that this function should only be used for cases where access is being denied based on credentials, not for when access is blocked for other reasons (such as to enforce flow security). Using warn_exit with INTERNAL_ERROR is for those situations, or log_hack_attack_and_exit.
 *
 * @param  ID_TEXT $class The class of error (e.g. PRIVILEGE). This is a language string codename.
 * @param  string $param The parameter given to the error message
 * @param  boolean $force_login Force the user to login (even if perhaps they are logged in already)
 */
function access_denied(string $class = 'ACCESS_DENIED', string $param = '', bool $force_login = false)
{
    require_code('failure');
    _access_denied($class, $param, $force_login);
}

/**
 * Find if a member has access to a specified zone.
 *
 * @param  MEMBER $member_id The member being checked whether to have the access
 * @param  ID_TEXT $zone The zone being checked
 * @return boolean Whether the member has zone access
 */
function has_zone_access(int $member_id, string $zone) : bool
{
    if (running_script('upgrader')) {
        return true;
    }

    if ($zone == '') {
        return true;
    }

    global $ZONE_ACCESS_CACHE;

    if (isset($ZONE_ACCESS_CACHE[$member_id][$zone])) {
        $result = $ZONE_ACCESS_CACHE[$member_id][$zone];
        handle_permission_check_logging($member_id, 'has_zone_access', [$zone], $result);
        return $result;
    }

    // Not loaded yet, load, then re-call ourself...

    $groups = get_permission_where_clause_groups($member_id);
    if ($groups === null) {
        return true;
    }

    global $SMART_CACHE;
    static $run_once = false;
    $where = ' AND (1=0';
    if ((isset($SMART_CACHE)) && (!$run_once)) {
        $SMART_CACHE->append('zone_access_needed', $zone, true);
        $all_zone_access_needed = $SMART_CACHE->get('zone_access_needed');
        if ($all_zone_access_needed === null) {
            $all_zone_access_needed = [];
        }
    } else {
        $all_zone_access_needed = [];
    }
    $all_zone_access_needed[$zone] = true;
    foreach ($all_zone_access_needed as $zone_access_needed => $_) {
        if (is_integer($zone_access_needed)) {
            $zone_access_needed = strval($zone_access_needed);
        }
        $where .= ' OR ' . db_string_equal_to('zone_name', $zone_access_needed);
    }
    $where .= ')';
    $run_once = true;

    $sql = 'SELECT DISTINCT zone_name FROM ' . get_table_prefix() . 'group_zone_access WHERE (' . $groups . ') ' . $where;
    $sql .= ' UNION ALL ';
    $sql .= 'SELECT DISTINCT zone_name FROM ' . get_table_prefix() . 'member_zone_access WHERE member_id=' . strval($member_id) . ' AND (active_until IS NULL OR active_until>' . strval(time()) . ')' . $where;
    $rows = $GLOBALS['SITE_DB']->query($sql, null, 0, false, true);
    if (!array_key_exists($member_id, $ZONE_ACCESS_CACHE)) {
        $ZONE_ACCESS_CACHE[$member_id] = [];
    }
    foreach ($rows as $row) {
        $ZONE_ACCESS_CACHE[$member_id][$row['zone_name']] = true;
    }

    // We need to store negatives, so it can tell between "not loaded from DB" and "permission absent"
    foreach ($all_zone_access_needed as $zone_access_needed => $_) {
        if (is_integer($zone_access_needed)) {
            $zone_access_needed = strval($zone_access_needed);
        }
        if (!isset($ZONE_ACCESS_CACHE[$member_id][$zone_access_needed])) {
            $ZONE_ACCESS_CACHE[$member_id][$zone_access_needed] = false;
        }
    }

    return has_zone_access($member_id, $zone);
}

/**
 * Find if a member has access to a specified page. Zone permissions are taken into account for wherever the page is found at. Also support for category access and privileges. No support for entry-point checks, which are only carried out as an extension of page permissions when actually at a page.
 *
 * @param  ?MEMBER $member_id The member being checked whether to have the access (null: current member)
 * @param  ?ID_TEXT $page The page being checked (null: current page)
 * @param  ?ID_TEXT $zone The zone being checked (null: search)
 * @param  ?array $cats A list of cat details to require access to (c-type-1,c-id-1,c-type-2,c-d-2,...) (null: N/A)
 * @param  ?mixed $privilege Either the ID code of a privilege, an array of alternatives that are acceptable (null: none required)
 * @return boolean Whether the member has zone and page access
 */
function has_actual_page_access(?int $member_id = null, ?string $page = null, ?string $zone = null, ?array $cats = null, $privilege = null) : bool
{
    if (running_script('upgrader')) {
        return true;
    }

    if ($member_id === null) {
        $member_id = get_member();
    }

    if ($page === null) { // Assumes $zone is null too
        $page = get_page_name();
        $zone = get_zone_name();
    } else {
        if ($zone === '_SELF') {
            $zone = get_zone_name();
        } elseif (($zone === null) || ($zone == '_SEARCH')) {
            $zone = get_module_zone($page);
        }
        if ($zone === null) {
            $zone = get_zone_name(); // Weird problem that can happen on some AJAX hooks
        }
    }

    if (!has_zone_access($member_id, $zone)) {
        return false;
    }
    if (!has_page_access($member_id, $page, $zone)) {
        return false;
    }
    if ($cats !== null) {
        for ($i = 0; $i < intval(floor(floatval(count($cats)) / 2.0)); $i++) {
            if ($cats[$i * 2] === null) {
                continue;
            }
            if (!has_category_access($member_id, $cats[$i * 2 + 0], $cats[$i * 2 + 1])) {
                return false;
            }
        }
    }
    if ($privilege !== null) {
        if (!is_array($privilege)) {
            $privilege = [$privilege];
        }
        $privilege_acceptable = false;
        foreach ($privilege as $perm) {
            if ($perm !== null) {
                if (has_privilege($member_id, $perm, $page, $cats)) {
                    $privilege_acceptable = true;
                }
            }
        }
        if (!$privilege_acceptable) {
            return false;
        }
    }

    return true;
}

/**
 * Find if a member has access to a specified page, in a specific zone. Note that page access does not imply zone access; you have access a page, but not the zone, so still couldn't see it.
 *
 * @param  MEMBER $member_id The member being checked whether to have the access
 * @param  ID_TEXT $page The page being checked
 * @param  ID_TEXT $zone The zone being checked
 * @param  boolean $at_now Whether we want to check we have access to the CURRENT page, using any match-key permissions
 * @return boolean Whether the member has page access
 */
function has_page_access(int $member_id, string $page, string $zone, bool $at_now = false) : bool
{
    if (running_script('upgrader')) {
        return true;
    }

    global $PAGE_ACCESS_CACHE, $PAGE_ACCESS_CACHE_MATCH_KEYS;

    $page_access_needed = $zone . ':' . $page;
    if (isset($PAGE_ACCESS_CACHE[$member_id][$page_access_needed])) {
        // Check match keys, as these take precedence to cause possible denies (always denies, never grants)
        if (($at_now) && (isset($PAGE_ACCESS_CACHE_MATCH_KEYS[$member_id][$page_access_needed]))) {
            foreach ($PAGE_ACCESS_CACHE_MATCH_KEYS[$member_id][$page_access_needed] as $match_key => $result) {
                if ((!$result) && (match_key_match($match_key, true)/*We only know the start of it matches until we do this full check*/)) {
                    return false;
                }
            }
        }

        // Regular page access
        $result = $PAGE_ACCESS_CACHE[$member_id][$page_access_needed];
        handle_permission_check_logging($member_id, 'has_page_access', [$page_access_needed], $result);
        return $result;
    }

    // Not loaded yet, load, then re-call ourself...

    $groups = get_permission_where_clause_groups($member_id);
    if ($groups === null) {
        return true;
    }
    $groups2 = filter_group_permissivity($GLOBALS['FORUM_DRIVER']->get_members_groups($member_id, false));
    $group_cnt = count($groups2);

    global $SMART_CACHE;
    static $run_once = false;
    $where = ' AND (1=0';
    if ((isset($SMART_CACHE)) && (!$run_once)) {
        $SMART_CACHE->append('page_access_needed', $page_access_needed, true);
        $all_page_access_needed = $SMART_CACHE->get('page_access_needed');
        if ($all_page_access_needed === null) {
            $all_page_access_needed = [];
        }
    } else {
        $all_page_access_needed = [];
    }
    $all_page_access_needed[$page_access_needed] = true;
    foreach ($all_page_access_needed as $_page_access_needed => $_) {
        list($_zone, $_page) = explode(':', $_page_access_needed);
        $where .= ' OR (' . db_string_equal_to('zone_name', $_zone) . ' AND ' . db_string_equal_to('page_name', $_page) . ')';
        if (addon_installed('match_key_permissions')) {
            $where .= ' OR (' . db_string_equal_to('zone_name', '/') . ' AND (';
            $where .= 'page_name LIKE \'' . db_encode_like('\_WILD:' . $_page . ':%') . '\'';
            $where .= ' OR page_name LIKE \'' . db_encode_like('\_SEARCH:' . $_page . ':%') . '\'';
            $where .= ' OR page_name LIKE \'' . db_encode_like($_zone . ':' . $_page . ':%') . '\'';
            $where .= ' OR page_name LIKE \'' . db_encode_like('\_WILD:\_WILD:%') . '\'';
            $where .= ' OR page_name LIKE \'' . db_encode_like('\_SEARCH:\_WILD:%') . '\'';
            $where .= ' OR page_name LIKE \'' . db_encode_like($_zone . ':\_WILD:%') . '\'';
            $where .= ' OR page_name LIKE \'' . db_encode_like('\_WILD:' . $_page) . '\'';
            $where .= ' OR page_name LIKE \'' . db_encode_like('\_SEARCH:' . $_page) . '\'';
            $where .= ' OR page_name LIKE \'' . db_encode_like($_zone . ':' . $_page) . '\'';
            $where .= ' OR page_name LIKE \'' . db_encode_like('\_WILD:\_WILD') . '\'';
            $where .= ' OR page_name LIKE \'' . db_encode_like('\_SEARCH:\_WILD') . '\'';
            $where .= ' OR page_name LIKE \'' . db_encode_like($_zone . ':\_WILD') . '\'';
            $where .= ' OR page_name LIKE \'' . db_encode_like($_zone) . '\'';
            $where .= '))';
        }
    }
    $where .= ')';
    $run_once = true;

    $sql = 'SELECT zone_name,page_name,group_id FROM ' . get_table_prefix() . 'group_page_access WHERE (' . $groups . ')' . $where;
    $sql .= ' UNION ';
    $sql .= 'SELECT zone_name,page_name,NULL AS group_id FROM ' . get_table_prefix() . 'member_page_access WHERE member_id=' . strval($member_id) . ' AND (active_until IS NULL OR active_until>' . strval(time()) . ')' . $where;
    $rows = $GLOBALS['SITE_DB']->query($sql, null, 0, false, true);
    $rows_organised_for_groups = [];
    $rows_organised_for_member = [];
    $rows_organised_for_groups_match_key = [];
    $rows_organised_for_member_match_key = [];
    foreach ($rows as $row) {
        if ($row['zone_name'] == '/') {
            // Match-keys
            if ($row['group_id'] === null) {
                $rows_organised_for_member_match_key[$row['page_name']] = false;
            } else {
                $rows_organised_for_groups_match_key[$row['page_name']][$row['group_id']] = false;
            }
        } else {
            // Regular page access
            if ($row['group_id'] === null) {
                $rows_organised_for_member[$row['zone_name']][$row['page_name']] = false;
            } else {
                $rows_organised_for_groups[$row['zone_name']][$row['page_name']][$row['group_id']] = false;
            }
        }
    }

    if (!array_key_exists($member_id, $PAGE_ACCESS_CACHE)) {
        $PAGE_ACCESS_CACHE[$member_id] = [];
    }
    if (!array_key_exists($member_id, $PAGE_ACCESS_CACHE_MATCH_KEYS)) {
        $PAGE_ACCESS_CACHE_MATCH_KEYS[$member_id] = [];
    }
    foreach ($all_page_access_needed as $_page_access_needed => $_) {
        list($_zone, $_page) = explode(':', $_page_access_needed);

        // Regular page access
        if (!isset($rows_organised_for_groups[$_zone][$_page])) { // Not denied for anything
            $PAGE_ACCESS_CACHE[$member_id][$_page_access_needed] = true;
        } else { // Denied for some group(s) --> Granted if not denied for all groups or if access granted for member directly
            $PAGE_ACCESS_CACHE[$member_id][$_page_access_needed] = (count($rows_organised_for_groups[$_zone][$_page]) < $group_cnt) || (isset($rows_organised_for_member[$_zone][$_page]));
        }

        // Match-keys
        $regexp = [];
        $regexp[] = '(_WILD:' . preg_quote($_page, '#') . ':.*)';
        $regexp[] = '(' . preg_quote($_zone, '#') . ':' . preg_quote($_page, '#') . ':.*)';
        $regexp[] = '(_WILD:_WILD:.*)';
        $regexp[] = '(' . preg_quote($_zone, '#') . ':_WILD:.*)';
        $regexp[] = '(_WILD:' . preg_quote($_page, '#') . ')';
        $regexp[] = '(' . preg_quote($_zone, '#') . ':' . preg_quote($_page, '#') . ')';
        $regexp[] = '(_WILD:_WILD)';
        $regexp[] = '(' . preg_quote($_zone, '#') . ':_WILD)';
        $regexp[] = '(' . preg_quote($_zone, '#') . ')';
        foreach ($rows_organised_for_groups_match_key as $match_key => $bits) {
            if (preg_match('#^' . implode('|', $regexp) . '$#', $match_key) != 0) {
                $PAGE_ACCESS_CACHE_MATCH_KEYS[$member_id][$_page_access_needed][$match_key] = (count($bits) < $group_cnt) || (isset($rows_organised_for_member_match_key[$match_key]));
            }
        }
    }

    // Infinite loop prevention; we should never reach this point more than once on the same execution for the same parameters
    check_for_infinite_loop('has_page_access', func_get_args());

    // Had to populate cache, so we must check again now with the caches populated
    return has_page_access($member_id, $page, $zone, $at_now);
}

/**
 * For efficiency reasons, load up loads of category permissions.
 *
 * @param  MEMBER $member_id The member being checked whether to have the access
 * @param  ?ID_TEXT $permission_module The permission module being checked for category access (null: all categories)
 */
function load_up_all_module_category_permissions(int $member_id, ?string $permission_module = null)
{
    $groups = get_permission_where_clause_groups($member_id);
    if ($groups === null) {
        return;
    }

    global $CATEGORY_ACCESS_CACHE, $LOADED_ALL_CATEGORY_PERMISSIONS_FOR_CACHE;

    if (isset($LOADED_ALL_CATEGORY_PERMISSIONS_FOR_CACHE[$permission_module][$member_id])) {
        return;
    }

    if ($permission_module !== null) {
        $catclause = '(' . db_string_equal_to('module_the_name', $permission_module) . ') AND ';
        $select = 'category_name';
    } else {
        $catclause = '';
        $select = 'category_name,module_the_name';
    }
    $db = $GLOBALS[((($permission_module == 'forums') || ($permission_module == 'topics')) && (get_forum_type() == 'cns')) ? 'FORUM_DB' : 'SITE_DB'];
    if ($db->query_value_if_there('SELECT COUNT(*) FROM ' . $db->get_table_prefix() . 'group_category_access WHERE ' . $catclause . '(' . $groups . ')') > 1000) {
        return; // Performance issue
    }
    $sql = 'SELECT ' . $select . ' FROM ' . $db->get_table_prefix() . 'group_category_access WHERE ' . $catclause . '(' . $groups . ')';
    $sql .= ' UNION ALL ';
    $sql .= 'SELECT ' . $select . ' FROM ' . $db->get_table_prefix() . 'member_category_access WHERE ' . $catclause . 'member_id=' . strval($member_id) . ' AND (active_until IS NULL OR active_until>' . strval(time()) . ')';
    $perhaps = $db->query($sql, null, 0, false, true);

    $LOADED_ALL_CATEGORY_PERMISSIONS_FOR_CACHE[$permission_module][$member_id] = true;

    $CATEGORY_ACCESS_CACHE[$member_id] = [];
    foreach ($perhaps as $row) {
        if ($permission_module !== null) {
            $for = $permission_module . '/' . $row['category_name'];
        } else {
            $for = $row['module_the_name'] . '/' . $row['category_name'];
        }
        $CATEGORY_ACCESS_CACHE[$member_id][$for] = true;
    }
}

/**
 * Find if a member has access to a specified category.
 *
 * @param  MEMBER $member_id The member being checked whether to have the access
 * @param  ID_TEXT $permission_module The permission module being checked for category access
 * @param  ID_TEXT $category The category being checked for access (often, a number cast to a string)
 * @return boolean Whether the member has category access
 */
function has_category_access(int $member_id, string $permission_module, string $category) : bool
{
    if (running_script('upgrader') || running_script('install')) {
        return true;
    }

    global $CATEGORY_ACCESS_CACHE, $LOADED_ALL_CATEGORY_PERMISSIONS_FOR_CACHE;
    if ((isset($CATEGORY_ACCESS_CACHE[$member_id], $CATEGORY_ACCESS_CACHE[$member_id][$permission_module . '/' . $category]))) {
        handle_permission_check_logging($member_id, 'has_category_access', [$permission_module, $category], $CATEGORY_ACCESS_CACHE[$member_id][$permission_module . '/' . $category]);
        return $CATEGORY_ACCESS_CACHE[$member_id][$permission_module . '/' . $category];
    }

    // Not loaded yet, load, then re-call ourself...

    $groups = get_permission_where_clause_groups($member_id);
    if ($groups === null) {
        return true;
    }

    if (isset($LOADED_ALL_CATEGORY_PERMISSIONS_FOR_CACHE[$permission_module][$member_id])) {
        handle_permission_check_logging($member_id, 'has_category_access', [$permission_module, $category], false);
        return false; // As we know $CATEGORY_ACCESS_CACHE would have had a true entry if we did have access
    }

    $where = ' AND (1=0';
    if (($permission_module != 'forums') || (!is_on_multi_site_network())) {
        global $SMART_CACHE;
        static $run_once = false;
        if ((isset($SMART_CACHE)) && (!$run_once)) {
            $SMART_CACHE->append('category_access_needed', $permission_module . '/' . $category, true);
            $all_category_access_needed = $SMART_CACHE->get('category_access_needed');
            if ($all_category_access_needed === null) {
                $all_category_access_needed = [];
            }
        } else {
            $all_category_access_needed = [];
        }
    } else {
        $all_category_access_needed = [];
    }
    $all_category_access_needed[$permission_module . '/' . $category] = true;
    foreach ($all_category_access_needed as $category_access_needed_parts => $_) {
        list($permission_module_access_needed, $category_access_needed) = explode('/', $category_access_needed_parts);
        $where .= ' OR ' . db_string_equal_to('module_the_name', $permission_module_access_needed) . ' AND ' . db_string_equal_to('category_name', $category_access_needed);
    }
    $where .= ')';
    $run_once = true;

    $db = $GLOBALS[((($permission_module == 'forums') || ($permission_module == 'topics')) && (get_forum_type() == 'cns')) ? 'FORUM_DB' : 'SITE_DB'];
    $sql = 'SELECT DISTINCT category_name,module_the_name FROM ' . $db->get_table_prefix() . 'group_category_access WHERE (' . $groups . ') ' . $where;
    $sql .= ' UNION ALL ';
    $sql .= 'SELECT DISTINCT category_name,module_the_name FROM ' . $db->get_table_prefix() . 'member_category_access WHERE member_id=' . strval($member_id) . ' AND (active_until IS NULL OR active_until>' . strval(time()) . ')' . $where;
    $rows = $db->query($sql, null, 0, false, true);

    foreach ($rows as $row) {
        $CATEGORY_ACCESS_CACHE[$member_id][$row['module_the_name'] . '/' . $row['category_name']] = true;
    }

    // We need to store negatives, so it can tell between "not loaded from DB" and "permission absent"
    foreach ($all_category_access_needed as $category_access_needed_parts => $_) {
        list($module_access_needed, $category_access_needed) = explode('/', $category_access_needed_parts);
        if (!isset($CATEGORY_ACCESS_CACHE[$member_id][$module_access_needed . '/' . $category_access_needed])) {
            $CATEGORY_ACCESS_CACHE[$member_id][$module_access_needed . '/' . $category_access_needed] = false;
        }
    }

    return has_category_access($member_id, $permission_module, $category);
}

/**
 * Get the SQL WHERE clause to select for any usergroups the given member is in (gets combined with some condition(s), to check against every usergroup).
 *
 * @param  MEMBER $member_id The member who's usergroups will be OR'd
 * @param  boolean $consider_clubs Whether to consider clubs (pass this false if considering page permissions, which work via explicit-denys across all groups, which could not happen for clubs as those denys could not have been set in the UI)
 * @param  string $field_prefix Prefix (based on table aliasing) for field name
 * @return ?string The SQL query fragment (null: admin, so permission regardless)
 * @ignore
 */
function get_permission_where_clause_groups(int $member_id, bool $consider_clubs = true, string $field_prefix = '') : ?string
{
    if (!isset($GLOBALS['FORUM_DRIVER'])) {
        return '1=0';
    }

    if ($GLOBALS['FORUM_DRIVER']->is_super_admin($member_id)) {
        return null;
    }

    $groups = $GLOBALS['FORUM_DRIVER']->get_members_groups($member_id, false);
    if (!$consider_clubs) {
        $groups = filter_group_permissivity($groups);
    }
    $out = '';
    foreach ($groups as $id) {
        if (!is_numeric($id)) {
            $id = -10; // Workaround problems in some forum driver data
        }

        if ($out != '') {
            $out .= ' OR ';
        }
        $out .= $field_prefix . 'group_id=' . strval($id);
    }
    if ($out == '') {
        if ((!$consider_clubs) && (get_forum_type() == 'cns')) {
            return $field_prefix . 'group_id=' . strval(db_get_first_id()); // Hmm, user was just put in a club! :S
        }
        fatal_exit(do_lang_tempcode('MEMBER_NO_GROUP')); // Shouldn't happen
    }

    return $out;
}

/**
 * Get an SQL WHERE clause for checking member access to a category.
 *
 * @param  ID_TEXT $permission_module The permission module being checked for
 * @param  ID_TEXT $category_field The field in the main query row that holds the category
 * @param  ?MEMBER $member_id The member to check individual access for (null: none)
 * @param  string $groups SQL from get_permission_where_clause_groups
 * @param  ID_TEXT $row_alias The alias for the main query row
 * @return string SQL
 */
function get_category_permission_where_clause(string $permission_module, string $category_field, ?int $member_id, string $groups, string $row_alias = 'r') : string
{
    $_category_field = ((empty($row_alias) || (strpos($category_field, '.') !== false)) ? '' : ($row_alias . '.')) . $category_field;

    $query = ' AND (';
    $query .= 'EXISTS(SELECT * FROM ' . get_table_prefix() . 'group_category_access a WHERE ' . db_string_equal_to('a.module_the_name', $permission_module) . ' AND ' . $_category_field . '=a.category_name AND (' . $groups . '))';
    if ($member_id !== null) {
        $query .= ' OR ';
        $query .= 'EXISTS(SELECT * FROM ' . get_table_prefix() . 'member_category_access ma WHERE ' . db_string_equal_to('ma.module_the_name', $permission_module) . ' AND ' . $_category_field . '=ma.category_name AND (ma.active_until IS NULL OR ma.active_until>' . strval(time()) . ') AND ma.member_id=' . strval($member_id) . ')';
    }
    $query .= ')';
    return $query;
}

/**
 * Get an SQL WHERE clause for checking member access to a page.
 *
 * @param  ID_TEXT $zone_field The field in the main query row that holds the zone
 * @param  ID_TEXT $page_field The field in the main query row that holds the page
 * @param  ?MEMBER $member_id The member to check individual access for (null: none)
 * @param  string $groups SQL from get_permission_where_clause_groups
 * @param  ID_TEXT $row_alias The alias for the main query row
 * @return string SQL
 */
function get_page_permission_where_clause(string $zone_field, string $page_field, ?int $member_id, string $groups, string $row_alias = 'r') : string
{
    $query = get_zone_permission_where_clause($zone_field, $member_id, $groups, $row_alias);
    $query .= ' AND (';
    $query .= 'EXISTS(SELECT * FROM ' . get_table_prefix() . 'group_page_access a WHERE ' . $row_alias . '.' . $zone_field . '=a.zone_name AND ' . $row_alias . '.' . $page_field . '=a.page_name AND (' . $groups . '))';
    if ($member_id !== null) {
        $query .= ' OR ';
        $query .= 'EXISTS(SELECT * FROM ' . get_table_prefix() . 'member_page_access ma WHERE ' . $row_alias . '.' . $zone_field . '=ma.zone_name AND ' . $row_alias . '.' . $page_field . '=ma.page_name AND (ma.active_until IS NULL OR ma.active_until>' . strval(time()) . ') AND ma.member_id=' . strval($member_id) . ')';
    }
    $query .= ')';
    return $query;
}

/**
 * Get an SQL WHERE clause for checking member access to a zone.
 *
 * @param  ID_TEXT $zone_field The field in the main query row that holds the zone
 * @param  ?MEMBER $member_id The member to check individual access for (null: none)
 * @param  string $groups SQL from get_permission_where_clause_groups
 * @param  ID_TEXT $row_alias The alias for the main query row
 * @return string SQL
 */
function get_zone_permission_where_clause(string $zone_field, ?int $member_id, string $groups, string $row_alias = 'r') : string
{
    $query = ' AND (';
    $query .= 'EXISTS(SELECT * FROM ' . get_table_prefix() . 'group_zone_access a WHERE ' . $row_alias . '.' . $zone_field . '=a.zone_name AND (' . $groups . '))';
    if ($member_id !== null) {
        $query .= ' OR ';
        $query .= 'EXISTS(SELECT * FROM ' . get_table_prefix() . 'member_zone_access ma WHERE ' . $row_alias . '.' . $zone_field . '=ma.zone_name AND (ma.active_until IS NULL OR ma.active_until>' . strval(time()) . ') AND ma.member_id=' . strval($member_id) . ')';
    }
    $query .= ')';
    return $query;
}

/**
 * Find which of a list of usergroups are permissive ones.
 *
 * @param  array $groups List of groups to filter
 * @return array List of permissive groups, filtered from those given
 */
function filter_group_permissivity(array $groups) : array
{
    if (count($groups) == 1) {
        return $groups;
    }

    asort($groups);

    if (get_forum_type() == 'cns') {
        static $permissive_groups = null;
        if ($permissive_groups === null) {
            $permissive_groups = collapse_1d_complexity('id', $GLOBALS['FORUM_DB']->query_select('f_groups', ['id'], ['g_is_private_club' => 0]));
        }

        $groups_new = [];
        foreach ($groups as $id) {
            if (in_array($id, $permissive_groups)) {
                $groups_new[] = $id;
            }
        }
        return $groups_new;
    }
    return $groups;
}

/**
 * Only allow members here that are either the give member, admins, or have a privilege. All other members come up to an error message wall.
 *
 * @param  MEMBER $member_id The member who typically (i.e. when it's not an administrative override) we want the current member to be.
 * @param  ?ID_TEXT $privilege The override privilege the current member must have (null: no general override)
 * @param  ?ID_TEXT $privilege2 An alternative privilege to the 'assume_any_member' privilege (null: no override)
 * @param  ?MEMBER $member_viewing The member who is doing the viewing (null: current member)
 */
function enforce_personal_access(int $member_id, ?string $privilege = null, ?string $privilege2 = null, ?int $member_viewing = null)
{
    if ($member_viewing === null) {
        $member_viewing = get_member();
    }

    if (is_guest($member_id)) {
        warn_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('d17551a0f7d55912ab78d282e9577ca3')));
    }
    if ((!has_privilege($member_viewing, 'assume_any_member')) && (($privilege2 === null) || (!has_privilege($member_viewing, $privilege2)))) {
        if (($member_id != $member_viewing) || (($privilege !== null) && (!has_privilege($member_viewing, $privilege)))) {
            if ($privilege !== null) {
                access_denied('PRIVILEGE', $privilege);
            } else {
                access_denied('PRIVILEGE', ($privilege2 === null) ? 'assume_any_member' : $privilege2);
            }
        }
    }
}

/**
 * Require presence of a privilege for the current member; otherwise exit.
 *
 * @param  ID_TEXT $privilege The privilege to require
 * @param  ?array $cats A list of cat details to require access to (c-type-1,c-id-1,c-type-2,c-d-2,...) (null: N/A)
 * @param  ?MEMBER $member_id Member to check for (null: current user)
 * @param  ?ID_TEXT $page_name Page name to check for (null: current page)
 */
function check_privilege(string $privilege, ?array $cats = null, ?int $member_id = null, ?string $page_name = null)
{
    if ($page_name === null) {
        $page_name = get_page_name();
    }
    if ($member_id === null) {
        $member_id = get_member();
    }
    if (!has_privilege($member_id, $privilege, $page_name, $cats)) {
        access_denied('PRIVILEGE', $privilege);
    }
}

/**
 * Find if a member has a specified privilege in any category.
 *
 * @param  MEMBER $member_id The member being checked whether to have the privilege
 * @param  ID_TEXT $privilege The privilege being checked for
 * @param  ?ID_TEXT $page The page being checked (null: current page)
 * @param  ID_TEXT $permission_module The permission module being checked for
 * @return boolean Whether the member has the privilege
 */
function has_some_cat_privilege(int $member_id, string $privilege, ?string $page, string $permission_module) : bool
{
    $page_wide_test = has_privilege($member_id, $privilege, $page); // To make sure privileges are cached, and test if page-wide or site-wide exists
    if ($page_wide_test) {
        return true;
    }

    global $PRIVILEGE_CACHE;
    if ((array_key_exists($member_id, $PRIVILEGE_CACHE)) && (array_key_exists($privilege, $PRIVILEGE_CACHE[$member_id])) && (array_key_exists('', $PRIVILEGE_CACHE[$member_id][$privilege])) && (array_key_exists($permission_module, $PRIVILEGE_CACHE[$member_id][$privilege]['']))) {
        foreach ($PRIVILEGE_CACHE[$member_id][$privilege][''][$permission_module] as $_privilege) {
            if ($_privilege) {
                return true;
            }
        }
    }

    return false;
}

/**
 * Find if a member has a specified privilege.
 *
 * @param  MEMBER $member_id The member being checked whether to have the privilege
 * @param  ID_TEXT $privilege The privilege being checked for
 * @param  ?ID_TEXT $page The page being checked (null: current page)
 * @param  ?mixed $cats A list of cat details to require access to (c-type-1,c-id-1,c-type-2,c-d-2,...), or a string of the cat type if you will accept overrides in any matching cat (null: N/A)
 * @return boolean Whether the member has the privilege
 */
function has_privilege(int $member_id, string $privilege, ?string $page = null, $cats = null) : bool
{
    if (running_script('upgrader')) {
        return true;
    }
    if ($GLOBALS['IN_MINIKERNEL_VERSION']) {
        return true;
    }

    if ($page === null) {
        $page = str_replace('-', '_', get_param_string('page', '')); // Not get_page_name for bootstrap order reasons
    }

    global $SPAM_REMOVE_VALIDATION;
    if (($SPAM_REMOVE_VALIDATION) && ($member_id == get_member()) && (($privilege == 'bypass_validation_highrange_content') || ($privilege == 'bypass_validation_midrange_content') || ($privilege == 'bypass_validation_lowrange_content'))) {
        return false;
    }

    $groups = get_permission_where_clause_groups($member_id);
    if ($groups === null) {
        return true;
    }

    global $PRIVILEGE_CACHE;

    if (isset($PRIVILEGE_CACHE[$member_id])) {
        if ($cats !== null) {
            if ($GLOBALS['DEV_MODE']) {
                if (count($cats) % 2 != 0) {
                    fatal_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('f7b5e5173a43574e99ba430e7ce3ebcb')));
                }
            }

            $okay = false;
            if (is_array($cats)) { // Specific overrides for cats allowed
                for ($i = 0; $i < @intval(floor(floatval(count($cats)) / 2.0)); $i++) {
                    if ($cats[$i * 2] === null) {
                        continue;
                    }
                    if (isset($PRIVILEGE_CACHE[$member_id][$privilege][''][$cats[$i * 2 + 0]][$cats[$i * 2 + 1]])) {
                        $result = $PRIVILEGE_CACHE[$member_id][$privilege][''][$cats[$i * 2 + 0]][$cats[$i * 2 + 1]];
                        if (!$result) { // Negative overrides take precedence over positive ones; got to be careful of that!
                            handle_permission_check_logging($member_id, 'has_privilege', array_merge([$privilege, $page], ($cats === null) ? [] : $cats), $result);
                            return $result;
                        }
                        $okay = true;
                    }
                }
            } else { // Any overrides for cats allowed
                if (isset($PRIVILEGE_CACHE[$member_id][$privilege][''])) {
                    foreach ($PRIVILEGE_CACHE[$member_id][$privilege][''] as $result) {
                        if ($result) {
                            $okay = true;
                            break;
                        }
                    }
                }
            }
            if ($okay) {
                $result = $okay;
                handle_permission_check_logging($member_id, 'has_privilege', array_merge([$privilege, $page], ($cats === null) ? [] : (is_array($cats) ? $cats : [$cats])), $result);
                return $result;
            }
        }
        if ($page != '') {
            if (isset($PRIVILEGE_CACHE[$member_id][$privilege][$page][''][''])) {
                $result = $PRIVILEGE_CACHE[$member_id][$privilege][$page][''][''];
                handle_permission_check_logging($member_id, 'has_privilege', array_merge([$privilege, $page], ($cats === null) ? [] : (is_array($cats) ? $cats : [$cats])), $result);
                return $result;
            }
        }
        if (isset($PRIVILEGE_CACHE[$member_id][$privilege][''][''][''])) {
            $result = $PRIVILEGE_CACHE[$member_id][$privilege][''][''][''];
            handle_permission_check_logging($member_id, 'has_privilege', array_merge([$privilege, $page], ($cats === null) ? [] : (is_array($cats) ? $cats : [$cats])), $result);
            return $result;
        }
    }

    // Not loaded yet, load, then re-call ourself...

    global $SMART_CACHE;
    static $run_once = false;
    $where = ' AND (1=0';
    if ((isset($SMART_CACHE)) && (!$run_once)) {
        $SMART_CACHE->append('privileges_needed', $privilege, true);
        $all_privileges_needed = $SMART_CACHE->get('privileges_needed');
        if ($all_privileges_needed === null) {
            $all_privileges_needed = [];
        }
    } else {
        $all_privileges_needed = [];
    }
    $all_privileges_needed[$privilege] = true;
    foreach ($all_privileges_needed as $privilege_needed => $_) {
        if (is_integer($privilege_needed)) {
            $privilege_needed = strval($privilege_needed);
        }
        $where .= ' OR ' . db_string_equal_to('privilege', $privilege_needed);
    }
    $where .= ')';
    $run_once = true;

    $sql = 'SELECT privilege,the_page,module_the_name,category_name,the_value FROM ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'group_privileges WHERE (' . $groups . ')' . $where;
    $sql .= ' UNION ALL ';
    $sql .= 'SELECT privilege,the_page,module_the_name,category_name,the_value FROM ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'member_privileges WHERE member_id=' . strval($member_id) . ' AND (active_until IS NULL OR active_until>' . strval(time()) . ')' . $where;
    $perhaps = $GLOBALS['SITE_DB']->query($sql, null, 0, false, true);
    if (is_on_multi_site_network() && (get_forum_type() == 'cns')) {
        $sql = 'SELECT privilege,the_page,module_the_name,category_name,the_value FROM ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'group_privileges WHERE (' . $groups . ') AND ' . db_string_equal_to('module_the_name', 'forums') . $where;
        $sql .= ' UNION ALL ';
        $sql .= 'SELECT privilege,the_page,module_the_name,category_name,the_value FROM ' . $GLOBALS['FORUM_DB']->get_table_prefix() . 'member_privileges WHERE ' . db_string_equal_to('module_the_name', 'forums') . ' AND member_id=' . strval($member_id) . ' AND (active_until IS NULL OR active_until>' . strval(time()) . ')' . $where;
        $perhaps = array_merge($perhaps, $GLOBALS['FORUM_DB']->query($sql, null, null, false, true));
    }
    if (!array_key_exists($member_id, $PRIVILEGE_CACHE)) {
        $PRIVILEGE_CACHE[$member_id] = [];
    }
    foreach ($perhaps as $p) {
        if (empty($PRIVILEGE_CACHE[$member_id][$p['privilege']][$p['the_page']][$p['module_the_name']][$p['category_name']])) {
            $PRIVILEGE_CACHE[$member_id][$p['privilege']][$p['the_page']][$p['module_the_name']][$p['category_name']] = ($p['the_value'] == 1);
        }
    }

    // We need to store negatives, so it can tell between "not loaded from DB" and "permission absent"
    foreach ($all_privileges_needed as $privilege_needed => $_) {
        if (is_integer($privilege_needed)) {
            $privilege_needed = strval($privilege_needed);
        }
        if (!isset($PRIVILEGE_CACHE[$member_id][$privilege_needed][''][''][''])) {
            $PRIVILEGE_CACHE[$member_id][$privilege_needed][''][''][''] = false;
        }
    }

    return has_privilege($member_id, $privilege, $page, $cats);
}

/**
 * Check to see if a member has permission to submit an item. If it doesn't, an error message is outputted.
 *
 * @param  string $range The range of privilege we are checking to see if they have; these ranges are like trust levels
 * @set low mid high cat_low cat_mid cat_high
 * @param  ?array $cats A list of cat details to require access to (c-type-1,c-id-1,c-type-2,c-d-2,...) (null: N/A)
 * @param  ?ID_TEXT $page The page being checked (null: current page)
 */
function check_submit_permission(string $range, ?array $cats = null, ?string $page = null)
{
    if ($page === null) {
        $page = get_page_name();
    }

    if (!has_submit_permission($range, get_member(), get_ip_address(), $page, $cats)) {
        access_denied('PRIVILEGE', 'submit_' . $range . 'range_content');
    }
}

/**
 * Find if a member has permission to submit.
 *
 * @param  string $range The range of privilege we are checking to see if they have; these ranges are like trust levels
 * @set low mid high cat_low cat_mid cat_high
 * @param  MEMBER $member_id The member being checked whether to have the access
 * @param  IP $ip The member's IP address
 * @param  ?ID_TEXT $page The page being checked (null: current page)
 * @param  ?array $cats A list of cat details to require access to (c-type-1,c-id-1,c-type-2,c-d-2,...) (null: N/A)
 * @return boolean Whether the member can submit in this range
 */
function has_submit_permission(string $range, int $member_id, string $ip, ?string $page, ?array $cats = null) : bool
{
    global $SUBMIT_PERMISSION_CACHE, $USERSUBMITBAN_MEMBER_CACHE;
    if (isset($SUBMIT_PERMISSION_CACHE[$range][$member_id][$ip][$page][($cats === null) ? '' : serialize($cats)])) {
        return $SUBMIT_PERMISSION_CACHE[$range][$member_id][$ip][$page][($cats === null) ? '' : serialize($cats)];
    }

    $result = null;

    if ((addon_installed('securitylogging')) && (!$GLOBALS['FORUM_DRIVER']->is_super_admin(get_member())) && ((get_value('pinpoint_submitban_check') !== '1') || (get_zone_name() == 'cms'))) {
        if ($USERSUBMITBAN_MEMBER_CACHE === null) {
            $test = $GLOBALS['SITE_DB']->query_select_value_if_there('usersubmitban_member', 'the_member', ['the_member' => $member_id]);
            $USERSUBMITBAN_MEMBER_CACHE = ($test !== null);
        }
        if ($USERSUBMITBAN_MEMBER_CACHE) {
            $result = false;
        }
    }

    if ($result === null) {
        $result = has_privilege($member_id, 'submit_' . $range . 'range_content', $page, $cats);
    }

    $SUBMIT_PERMISSION_CACHE[$range][$member_id][$ip][$page][($cats === null) ? '' : serialize($cats)] = $result;

    return $result;
}

/**
 * Check to see if a member has permission to edit any of an item. If it doesn't, an error message is outputted.
 *
 * @param  string $range The range of privilege we are checking to see if they have; these ranges are like trust levels
 * @set low mid high cat_low cat_mid cat_high
 * @param  ?array $cats A list of cat details to require access to (c-type-1,c-id-1,c-type-2,c-d-2,...) (null: N/A)
 * @param  ?ID_TEXT $page The page being checked (null: current page)
 */
function check_some_edit_permission(string $range, ?array $cats = null, ?string $page = null)
{
    if ($page === null) {
        $page = get_page_name();
    }

    $ret = false;
    $member_id = get_member();
    if (is_guest($member_id)) {
        $ret = false;
    }
    if (has_privilege($member_id, 'edit_' . $range . 'range_content', get_page_name(), $cats)) {
        $ret = true;
    }
    if (has_privilege($member_id, 'edit_own_' . $range . 'range_content', get_page_name(), $cats)) {
        $ret = true;
    }

    if (!$ret) {
        access_denied('PRIVILEGE', 'edit_own_' . $range . 'range_content');
    }
}

/**
 * Check to see if a member has permission to edit an item. If it doesn't, an error message is outputted.
 *
 * @param  string $range The range of privilege we are checking to see if they have; these ranges are like trust levels
 * @set low mid high cat_low cat_mid cat_high
 * @param  ?MEMBER $resource_owner The member that owns this resource (null: no-one)
 * @param  ?array $cats A list of cat details to require access to (c-type-1,c-id-1,c-type-2,c-d-2,...) (null: N/A)
 * @param  ?ID_TEXT $page The page being checked (null: current page)
 */
function check_edit_permission(string $range, ?int $resource_owner, ?array $cats = null, ?string $page = null)
{
    if ($page === null) {
        $page = get_page_name();
    }

    if (!has_edit_permission($range, get_member(), $resource_owner, $page, $cats)) {
        access_denied('PRIVILEGE', 'edit_' . (($resource_owner == get_member()) ? 'own_' : '') . $range . 'range_content');
    }
}

/**
 * Find if a member has permission to edit.
 *
 * @param  string $range The range of privilege we are checking to see if they have; these ranges are like trust levels
 * @set low mid high cat_low cat_mid cat_high
 * @param  MEMBER $member_id The member being checked for access
 * @param  ?MEMBER $resource_owner The member that owns this resource (null: no-one)
 * @param  ?ID_TEXT $page The page being checked (null: current page)
 * @param  ?array $cats A list of cat details to require access to (c-type-1,c-id-1,c-type-2,c-d-2,...) (null: N/A)
 * @return boolean Whether the member may edit the resource
 */
function has_edit_permission(string $range, int $member_id, ?int $resource_owner, ?string $page, ?array $cats = null) : bool
{
    if (is_guest($member_id)) {
        return false;
    }
    if (($resource_owner !== null) && ($member_id == $resource_owner) && (has_privilege($member_id, 'edit_own_' . $range . 'range_content', $page, $cats))) {
        return true;
    }
    if (has_privilege($member_id, 'edit_' . $range . 'range_content', $page, $cats)) {
        return true;
    }
    return false;
}

/**
 * Check if a member has permission to delete a specific resource. If it doesn't, an error message is outputted.
 *
 * @param  string $range The range of privilege we are checking to see if they have; these ranges are like trust levels
 * @set low mid high cat_low cat_mid cat_high
 * @param  ?MEMBER $resource_owner The member that owns this resource (null: no-one)
 * @param  ?array $cats A list of cat details to require access to (c-type-1,c-id-1,c-type-2,c-d-2,...) (null: N/A)
 * @param  ?ID_TEXT $page The page being checked (null: current page)
 */
function check_delete_permission(string $range, ?int $resource_owner, ?array $cats = null, ?string $page = null)
{
    if ($page === null) {
        $page = get_page_name();
    }

    if (!has_delete_permission($range, get_member(), $resource_owner, $page, $cats)) {
        access_denied('PRIVILEGE', 'delete_' . (($resource_owner == get_member()) ? 'own_' : '') . $range . 'range_content');
    }
}

/**
 * Check to see if a member has permission to delete a specific resource.
 *
 * @param  string $range The range of privilege we are checking to see if they have; these ranges are like trust levels
 * @set low mid high cat_low cat_mid cat_high
 * @param  MEMBER $member_id The member being checked for access
 * @param  ?MEMBER $resource_owner The member that owns this resource (null: no-one)
 * @param  ?ID_TEXT $page The page being checked (null: current page)
 * @param  ?array $cats A list of cat details to require access to (c-type-1,c-id-1,c-type-2,c-d-2,...) (null: N/A)
 * @return boolean Whether the member may delete the resource
 */
function has_delete_permission(string $range, int $member_id, ?int $resource_owner, ?string $page, ?array $cats = null) : bool
{
    if (is_guest($member_id)) {
        return false;
    }
    if (($resource_owner !== null) && ($member_id == $resource_owner) && (has_privilege($member_id, 'delete_own_' . $range . 'range_content', $page, $cats))) {
        return true;
    }
    if (has_privilege($member_id, 'delete_' . $range . 'range_content', $page, $cats)) {
        return true;
    }
    return false;
}

/**
 * Check to see if a member has add permission for Comcode pages.
 *
 * @param  ?ID_TEXT $zone The zone of Comcode pages we need it in (null: ANY zone, we are doing a vague check if the user could possibly)
 * @param  ?MEMBER $member_id The member being checked for access (null: current member)
 * @return boolean If the permission is there
 */
function has_add_comcode_page_permission(?string $zone = null, ?int $member_id = null) : bool
{
    if ($member_id === null) {
        $member_id = get_member();
    }

    if ($zone !== null) {
        if (!has_zone_access($member_id, $zone)) {
            return false;
        }
    }
    if (!has_actual_page_access($member_id, 'cms_comcode_pages')) {
        return false;
    }

    $cats = mixed();
    $cats = 'zone_page';
    if ($zone !== null) {
        $cats = ['zone_page', $zone];
    }
    return has_privilege($member_id, 'submit_highrange_content', 'cms_comcode_pages', $cats);
}

/**
 * Check to see if a member has bypass-validation permission for Comcode pages.
 *
 * @param  ?ID_TEXT $zone The zone of Comcode pages we need it in (null: ANY zone, we are doing a vague check if the user could possibly)
 * @param  ?MEMBER $member_id The member being checked for access (null: current member)
 * @return boolean If the permission is there
 */
function has_bypass_validation_comcode_page_permission(?string $zone = null, ?int $member_id = null) : bool
{
    if ($member_id === null) {
        $member_id = get_member();
    }

    if ($zone !== null) {
        if (!has_zone_access($member_id, $zone)) {
            return false;
        }
    }
    if (!has_actual_page_access($member_id, 'cms_comcode_pages')) {
        return false;
    }

    $cats = mixed();
    $cats = 'zone_page';
    if ($zone !== null) {
        $cats = ['zone_page', $zone];
    }
    return has_privilege($member_id, 'bypass_validation_highrange_content', 'cms_comcode_pages', $cats);
}

/**
 * Check to see if a member has permission to edit a Comcode page.
 *
 * @param  integer $scope A bitmask of COMCODE_EDIT_* constants, identifying what kind of editing privilege we are looking for
 * @param  ?ID_TEXT $zone Zone to check for (null: check against global privileges, ignoring all per-zone overrides). Note how this is different to how a null zone works for checking add/bypass-validation privileges because if we get a false we have the get_comcode_page_editability_per_zone function to get more specific details, while for adding we either want a very specific or very vague answer.
 * @param  ?MEMBER $member_id The member being checked for access (null: current member)
 * @return boolean If the permission is there
 */
function has_some_edit_comcode_page_permission(int $scope, ?string $zone = null, ?int $member_id = null) : bool
{
    if ($member_id === null) {
        $member_id = get_member();
    }

    if ($zone !== null) {
        if (!has_zone_access($member_id, $zone)) {
            return false;
        }
    }
    if (!has_actual_page_access($member_id, 'cms_comcode_pages')) {
        return false;
    }

    $cats = null;
    if ($zone !== null) {
        $cats = ['zone_page', $zone];
    }

    if (($scope & COMCODE_EDIT_ANY) != 0) {
        if (has_privilege($member_id, 'edit_highrange_content', 'cms_comcode_pages', $cats)) {
            return true;
        }
    }

    if (($scope & COMCODE_EDIT_OWN) != 0) {
        if (has_privilege($member_id, 'edit_own_highrange_content', 'cms_comcode_pages', $cats)) {
            return true;
        }
    }

    return false;
}

/**
 * Find what zones a member may edit Comcode pages in.
 *
 * @param  ?MEMBER $member_id The member being checked for access (null: current member)
 * @return array A list of pairs: The zone name, and a bitmask of COMCODE_EDIT_* constants identifying the level of editing permission present
 */
function get_comcode_page_editability_per_zone(?int $member_id = null) : array
{
    $zones = [];

    $_zones = find_all_zones();
    foreach ($_zones as $zone) {
        $mask = COMCODE_EDIT_NONE;

        if (has_some_edit_comcode_page_permission(COMCODE_EDIT_ANY, $zone, $member_id)) {
            $mask = $mask | COMCODE_EDIT_ANY;
        } elseif (has_some_edit_comcode_page_permission(COMCODE_EDIT_OWN, $zone, $member_id)) {
            $mask = $mask | COMCODE_EDIT_OWN;
        }

        if ($mask != COMCODE_EDIT_NONE) {
            $zones[] = [$zone, $mask];
        }
    }

    return $zones;
}

/**
 * Check to see if a member has permission to edit a specific Comcode page.
 *
 * @param  ID_TEXT $zone The zone of the page
 * @param  ID_TEXT $page The name of the page
 * @param  ?MEMBER $owner Owner of the page (null: look it up)
 * @param  ?MEMBER $member_id The member being checked for access (null: current member)
 * @return boolean If the permission is there
 */
function has_edit_comcode_page_permission(string $zone, string $page, ?int $owner = null, ?int $member_id = null) : bool
{
    if ($member_id === null) {
        $member_id = get_member();
    }

    if ($owner === null) {
        $owner = $GLOBALS['SITE_DB']->query_select_value_if_there('comcode_pages', 'p_submitter', ['the_zone' => $zone, 'the_page' => $page]);
    }

    if (!has_actual_page_access($member_id, $page, $zone)) {
        return false;
    }
    if (!has_actual_page_access($member_id, 'cms_comcode_pages')) {
        return false;
    }

    $is_owner = (($owner == $member_id) && (!is_guest($member_id)));
    $privilege = $is_owner ? 'edit_own_highrange_content' : 'edit_highrange_content';

    $cats = null;
    if ($zone !== null) {
        $cats = ['zone_page', $zone];
    }

    return has_privilege($member_id, $privilege, 'cms_comcode_pages', $cats);
}

/**
 * Check to see if a member has permission to delete a specific Comcode page.
 *
 * @param  ID_TEXT $zone The zone of the page
 * @param  ID_TEXT $page The name of the page
 * @param  ?MEMBER $owner Owner of the page (null: look it up)
 * @param  ?MEMBER $member_id The member being checked for access (null: current member)
 * @return boolean If the permission is there
 */
function has_delete_comcode_page_permission(string $zone, string $page, ?int $owner = null, ?int $member_id = null) : bool
{
    if ($member_id === null) {
        $member_id = get_member();
    }

    if ($owner === null) {
        $owner = $GLOBALS['SITE_DB']->query_select_value_if_there('comcode_pages', 'p_submitter', ['the_zone' => $zone, 'the_page' => $page]);
    }

    if (!has_actual_page_access($member_id, $page, $zone)) {
        return false;
    }
    if (!has_actual_page_access($member_id, 'cms_comcode_pages')) {
        return false;
    }

    $is_owner = (($owner == $member_id) && (!is_guest($member_id)));
    $privilege = $is_owner ? 'delete_own_highrange_content' : 'delete_highrange_content';

    $cats = null;
    if ($zone !== null) {
        $cats = ['zone_page', $zone];
    }

    return has_privilege($member_id, $privilege, 'cms_comcode_pages', $cats);
}
