<?php
/**
 * LLMS Content Sanitizer
 *
 * @package Website_LLMS_TXT
 */

if (!defined('ABSPATH')) {
    exit;
}

/**
 * Remove access gated content from a string, once.
 *
 * THE RULE THIS CLASS EXISTS TO ENFORCE: llms.txt must contain what an anonymous
 * visitor can read, and nothing else.
 *
 * It gets there by identity rather than by enumeration. Generation runs as
 * whoever triggered it, and on the ordinary editing path that is a logged-in
 * administrator, so every membership plugin renders its members-only branch and
 * writes the result to a public file. Expanding as user 0 makes those plugins
 * render the anonymous branch themselves, which is the same decision the front
 * end makes, made by the same code.
 *
 * Why that and not a denylist of gating tag names. Measured on a live WordPress
 * with Members and Paid Memberships Pro active, scored by anonymous curl: the
 * denylist closed 4 of 6 leaks and wrongly removed 2 published strings, and
 * expanding as user 0 closed 5 of 6 and removed nothing. The list cannot be
 * completed by hand. With three gating plugins active there were 46 registered
 * shortcodes, 9 of them on the list; Members' own [is_user_logged_in] was not,
 * and leaked. The denylist survives below only as a second line behind identity.
 *
 * The sixth leak is the one identity cannot reach: a gating BLOCK's inner HTML is
 * stored in post_content and read straight out of it, so nothing is ever expanded
 * and there is no user to be. That is what strip_gating_blocks() is for, and it is
 * the reason this class did not shrink to a single function.
 *
 * Details in docs/internals-content-sanitizer.md.
 */
class LLMS_Content_Sanitizer
{
    /** Raw post_content: expansion runs. */
    const PROFILE_FULL = 'full';

    /** Already expanded, or plain author text: expansion is skipped. */
    const PROFILE_TEXT = 'text';

    /** Core's shortcode tag name character class, generalised. */
    const TAG_REGEXP = '[a-zA-Z_][a-zA-Z0-9_-]*';

    /** @var int Nesting depth of guarded(), so an inner call cannot disarm an outer one. */
    private static $guard_depth = 0;

    /** @var string[] Parked escaped shortcodes for this call, token => literal. */
    private $parked = array();

    /**
     * @var string Per call park token prefix.
     *
     * Unpredictable on purpose. The previous fixed prefix was documented as safe
     * because "no author can type a control character", and authors do not only
     * type: a post arriving over the REST API from an external script carries
     * whatever bytes the script sends. A post containing the literal token then had
     * another passage substituted into it. Measured, as duplicated content.
     */
    private $park_prefix;

    /**
     * Mint this call's park prefix.
     *
     * Control characters so it cannot survive into output as readable text, and a
     * random component so author supplied bytes cannot forge one.
     */
    private function __construct()
    {
        $entropy = function_exists('random_bytes')
            ? bin2hex(random_bytes(8))
            : uniqid('', true);

        $this->park_prefix = "\x02LLMSLIT" . str_replace(array('[', ']'), '', $entropy) . "\x02";
    }

    /**
     * Sanitise one string exactly once.
     *
     * @param string $content
     * @param string $profile PROFILE_FULL or PROFILE_TEXT.
     * @return string
     */
    public static function sanitize($content, $profile = self::PROFILE_FULL)
    {
        if (!is_string($content) || '' === $content) {
            return '';
        }

        $sanitizer = new self();

        return $sanitizer->run($content, $profile);
    }

    /**
     * Run a callback with no current user, restoring the original afterwards.
     *
     * Scope this as tightly as possible around the expansion itself. It is global
     * state, and save_post callbacks belonging to other plugins run in the same
     * request: a switch held open across one of those changes what THEY do, which
     * is a bug in someone else's plugin that we caused.
     *
     * @param callable $callback
     * @return mixed
     */
    public static function as_anonymous($callback)
    {
        if (!function_exists('wp_set_current_user') || !function_exists('get_current_user_id')) {
            return call_user_func($callback);
        }

        $original = (int) get_current_user_id();

        // Already nobody, which is every cron and every front end request. Returning
        // early keeps set_current_user from firing twice on the common path.
        if (0 === $original) {
            return call_user_func($callback);
        }

        wp_set_current_user(0);

        try {
            return call_user_func($callback);
        } finally {
            wp_set_current_user($original);
        }
    }

    /**
     * Run a callback as nobody AND with the gating shortcode guard installed.
     *
     * The order matters only for readability; the two are independent. Callers that
     * expand content outside sanitize() want both, and asking for both at one call
     * site is what stops one of them being forgotten at the next one.
     *
     * @param callable $callback
     * @return mixed
     */
    public static function as_anonymous_guarded($callback)
    {
        return self::as_anonymous(function () use ($callback) {
            return self::guarded($callback);
        });
    }

    /**
     * Run a callback with the gating shortcode guard installed.
     *
     * @param callable $callback
     * @return mixed
     */
    public static function guarded($callback)
    {
        $guard = array(__CLASS__, 'guard_shortcode_tag');

        if (0 === self::$guard_depth) {
            add_filter('pre_do_shortcode_tag', $guard, 1, 4);
        }
        self::$guard_depth++;

        try {
            return call_user_func($callback);
        } finally {
            self::$guard_depth--;
            if (0 === self::$guard_depth) {
                remove_filter('pre_do_shortcode_tag', $guard, 1);
            }
        }
    }

    /**
     * Short circuit a gating shortcode so its callback never runs.
     *
     * @param false|string $return
     * @param string       $tag
     * @param array        $attr
     * @param array        $m
     * @return false|string
     */
    public static function guard_shortcode_tag($return, $tag, $attr = array(), $m = array())
    {
        return self::is_gating_shortcode($tag) ? '' : $return;
    }

    /**
     * Shortcode tag names treated as access gates.
     *
     * Second line only. Identity is what actually closes this class, and a name
     * missing from here is no longer a leak on its own. Kept because it costs
     * nothing and covers a gate that decides on something other than who is asking.
     *
     * @return string[]
     */
    public static function gating_shortcodes()
    {
        $tags = array(
            // Members.
            'members_access', 'members_logged_in', 'members_not_logged_in',
            'members_can', 'members_cannot', 'access', 'is_user_logged_in',
            // Paid Memberships Pro.
            'membership', 'pmpro_member', 'pmpro_membership', 'pmpro_has_membership_level',
            // Restrict Content, Restrict Content Pro.
            'restrict', 'not_logged_in', 'rcp_restrict', 'rcp_subscription_details',
            // MemberPress.
            'mepr-active', 'mepr-inactive', 'mepr-show', 'mepr-hide', 'mepr_protect',
            // WooCommerce Memberships.
            'wcm_restrict', 'wcm_member', 'wcm_nonmember',
            'wc_memberships_restrict', 'wc_memberships_member', 'wc_memberships_nonmember',
            // LifterLMS.
            'lifterlms_hide_content', 'lifterlms_membership_restricted',
            // Simple Membership.
            'swpm_protected', 'swpm_private', 'swpm_show_if_level', 'swpm_hide_if_level',
            // s2Member.
            's2If', 's2Private', 's2Restrict',
            // Ultimate Member, WP-Members, Groups, ARMember.
            'um_loggedin', 'um_loggedout', 'um_show_content',
            'wpmem_logged_in', 'wpmem_logged_out',
            'groups_member', 'groups_non_member',
            'arm_restrict_content', 'arm_content_restriction',
            // Conventions.
            'member_only', 'members_only', 'memberonly', 'subscriber_only',
            'private', 'protected', 'restricted', 'restricted_content', 'private_content',
            'logged_in', 'loggedin', 'loggedout', 'visitor_only', 'gate', 'paywall',
        );

        $tags = apply_filters('llms_gating_shortcodes', $tags);

        if (!is_array($tags)) {
            return array();
        }

        return array_values(array_filter(array_map('strval', $tags), 'strlen'));
    }

    /**
     * Block names treated as access gates whatever their registration says.
     *
     * @return string[]
     */
    public static function gating_blocks()
    {
        // pmpro/membership was read from shipped source. The rest are seed names, and
        // the dynamic block rule below is what protects a site running something else.
        $blocks = array(
            'pmpro/membership',
            'pmpro/membership-required',
            'memberpress/protected-content',
            'memberpress/protected-block',
            'restrict-content-pro/restrict',
            'rcp/restrict',
            'woocommerce-memberships/restrict-content',
            'simple-membership/protected-content',
            'members/access',
        );

        $blocks = apply_filters('llms_gating_blocks', $blocks);

        return is_array($blocks) ? array_map('strtolower', array_map('strval', $blocks)) : array();
    }

    /**
     * Block names exempt from the dynamic block rule.
     *
     * @return string[]
     */
    public static function dynamic_block_allowlist()
    {
        $allowed = apply_filters('llms_dynamic_block_allowlist', array());

        return is_array($allowed) ? array_map('strtolower', array_map('strval', $allowed)) : array();
    }

    /**
     * Strip gating blocks, expand as nobody, strip again, drop comments.
     *
     * Nothing here removes shortcode syntax. WordPress publishes an unregistered
     * shortcode as literal text, so removing one takes content off the file that
     * the front end shows: measured, twice, as [unreg_wrap]body[/unreg_wrap] and
     * [GATE]x[/GATE]. Leaving it alone is what agrees with the visitor.
     *
     * @param string $content
     * @param string $profile
     * @return string
     */
    private function run($content, $profile)
    {
        $content = $this->park_escaped_shortcodes($content);
        $content = $this->strip_gating_blocks($content);
        $content = $this->strip_gating_shortcodes($content);

        if (self::PROFILE_FULL === $profile && function_exists('do_shortcode')) {
            $content = (string) self::as_anonymous_guarded(function () use ($content) {
                return do_shortcode($content);
            });
        }

        // Shortcode output can carry block delimiters of its own, so this runs again.
        $content = $this->strip_gating_blocks($content);

        // Must stay below the structural pass: it would otherwise eat the block
        // delimiters parse_blocks() needs and leave every gated subtree behind.
        $content = $this->pcre(preg_replace('/<!--[\s\S]*?-->/', '', $content));

        return $this->restore_escaped_shortcodes($content);
    }

    /**
     * Replace escaped shortcodes with opaque tokens before anything else runs.
     *
     * WordPress publishes [[tag]body[/tag]] as the literal text [tag]body[/tag],
     * so every character of it is public and must survive. It cannot simply be
     * left alone: strip_gating_shortcodes() correctly UNescapes it, which hands a
     * live-looking shortcode to the expansion below, where the guard deletes a
     * denylisted one body and all. Parking it is what keeps a published passage
     * from being destroyed by the step that is supposed to protect it.
     *
     * Only escaped shortcodes are parked. Bracketed prose, markdown link labels
     * and reference markers need no protection now that nothing removes
     * shortcode-shaped text generically.
     *
     * @param string $content
     * @return string
     */
    private function park_escaped_shortcodes($content)
    {
        if (false === strpos($content, '[[')) {
            return $content;
        }

        return $this->pcre(preg_replace_callback(
            '/' . self::escaped_shortcode_regex() . '/s',
            function ($m) {
                $token = $this->park_prefix . count($this->parked) . "\x03";

                // The literal WordPress would publish: the outer brackets removed.
                $this->parked[$token] = substr($m[0], 1, -1);

                return $token;
            },
            $content
        ));
    }

    /**
     * Put the parked literals back.
     *
     * @param string $content
     * @return string
     */
    private function restore_escaped_shortcodes($content)
    {
        if (!$this->parked) {
            return $content;
        }

        // strtr() does not rescan what it substituted, and one escaped shortcode can
        // sit inside another, so this settles rather than running once.
        for ($pass = 0; $pass < 4; $pass++) {
            if (false === strpos($content, $this->park_prefix)) {
                break;
            }

            $content = strtr($content, $this->parked);
        }

        return $content;
    }

    /**
     * Core's shortcode regex, narrowed to the escaped form.
     *
     * @return string
     */
    private static function escaped_shortcode_regex()
    {
        // wp-includes/shortcodes.php, verbatim apart from the tag name and the two
        // escaping brackets being made mandatory. Required rather than optional
        // brackets is load bearing: with them optional, leftmost matching wins on
        // the ordinary WPBakery and Divi shape, an escaped shortcode inside a
        // non-escaped enclosing one, and the inner body is then deleted.
        return '\\[' . '(\\[)'
            . '(' . self::TAG_REGEXP . ')'
            . '(?![\\w-])'
            . '('
            .     '[^\\]\\/]*'
            .     '(?:'
            .         '\\/(?!\\])'
            .         '[^\\]\\/]*'
            .     ')*?'
            . ')'
            . '(?:'
            .     '(\\/)'
            .     '\\]'
            . '|'
            .     '\\]'
            .     '(?:'
            .         '('
            .             '[^\\[]*+'
            .             '(?:'
            .                 '\\[(?!\\/\\2\\])'
            .                 '[^\\[]*+'
            .             ')*+'
            .         ')'
            .         '\\[\\/\\2\\]'
            .     ')?'
            . ')'
            . '(\\])';
    }

    /**
     * Remove denylisted gating shortcodes with their bodies, registered or not.
     *
     * THE ONE THING IDENTITY CANNOT DO, and the reason this survived the cut.
     * Expanding as user 0 only helps if the shortcode is registered at generation
     * time. A plugin that registers its shortcodes on the front end alone leaves
     * [gate]secret[/gate] sitting as literal text during an admin-ajax rebuild,
     * where is_admin() is true, and literal text goes straight into the file while
     * an anonymous visitor sees the gate render and hide it.
     *
     * Narrow on purpose. It removes names on the denylist and nothing else. The
     * generic version of this, which removed every unregistered shortcode with its
     * body, is what deleted [unreg_wrap]published body[/unreg_wrap] from a file the
     * front end shows in full.
     *
     * Escaped shortcodes need no special handling here: core's strip_shortcode_tag()
     * returns the unescaped literal for the [[tag]body[/tag]] form rather than
     * removing it, which is what the front end publishes.
     *
     * @param string $content
     * @return string
     */
    private function strip_gating_shortcodes($content)
    {
        if (false === strpos($content, '[') || !function_exists('get_shortcode_regex')) {
            return $content;
        }

        // Narrowed to the tags actually present, so the alternation stays short.
        // stripos, and the regex below is case sensitive exactly as core is, so a
        // [GATE] that core would not expand is left alone here too. The front end
        // publishes that literally, so removing it would be over-removal.
        $present = array();
        foreach (self::gating_shortcodes() as $tag) {
            if (false !== stripos($content, '[' . $tag)) {
                $present[] = $tag;
            }
        }

        if (!$present) {
            return $content;
        }

        return $this->pcre(preg_replace_callback(
            '/' . get_shortcode_regex($present) . '/s',
            'strip_shortcode_tag',
            $content
        ));
    }

    /**
     * Drop gating block subtrees, keep everything else's stored HTML.
     *
     * @param string $content
     * @return string
     */
    private function strip_gating_blocks($content)
    {
        if (false === strpos($content, '<!-- wp:') || !function_exists('parse_blocks')) {
            return $content;
        }

        $out = '';
        foreach (parse_blocks($content) as $block) {
            $out .= $this->flatten_block($block);
        }

        return $out;
    }

    /**
     * Serialize one parsed block without its delimiters.
     *
     * @param array $block
     * @return string
     */
    private function flatten_block($block)
    {
        $name = isset($block['blockName']) ? (string) $block['blockName'] : '';

        if ('' !== $name && $this->block_is_gating($name)) {
            return '';
        }

        $inner = (isset($block['innerContent']) && is_array($block['innerContent']))
            ? $block['innerContent']
            : array();

        if (!$inner) {
            return isset($block['innerHTML']) ? (string) $block['innerHTML'] : '';
        }

        // The interleaving serialize_blocks() uses: a null is the next inner block.
        $out   = '';
        $index = 0;
        foreach ($inner as $chunk) {
            if (is_string($chunk)) {
                $out .= $chunk;
                continue;
            }

            if (isset($block['innerBlocks'][$index]) && is_array($block['innerBlocks'][$index])) {
                $out .= $this->flatten_block($block['innerBlocks'][$index]);
            }
            $index++;
        }

        return $out;
    }

    /**
     * Whether a block's stored inner content must not be published.
     *
     * @param string $name
     * @return bool
     */
    private function block_is_gating($name)
    {
        $lower = strtolower($name);

        if (in_array($lower, self::gating_blocks(), true)) {
            return true;
        }

        // core/ is exempt. core/heading, core/list-item, core/image and core/shortcode
        // are render callback blocks, and dropping those empties ordinary posts.
        if (0 === strpos($lower, 'core/')) {
            return false;
        }

        if (in_array($lower, self::dynamic_block_allowlist(), true)) {
            return false;
        }

        if (!apply_filters('llms_drop_dynamic_block_content', true, $name)) {
            return false;
        }

        if (!class_exists('WP_Block_Type_Registry')) {
            return false;
        }

        $type = WP_Block_Type_Registry::get_instance()->get_registered($name);

        return (is_object($type) && method_exists($type, 'is_dynamic') && $type->is_dynamic());
    }

    /**
     * A PCRE result, or '' when the engine gave up.
     *
     * @param string|null $result
     * @return string
     */
    private function pcre($result)
    {
        // Fail closed. A backtrack limit must not publish the unsanitised input.
        return is_string($result) ? $result : '';
    }

    /**
     * Whether a tag name is on the gating denylist.
     *
     * @param string $tag
     * @return bool
     */
    private static function is_gating_shortcode($tag)
    {
        $lower = strtolower((string) $tag);

        foreach (self::gating_shortcodes() as $known) {
            if ($lower === strtolower($known)) {
                return true;
            }
        }

        return false;
    }
}
