false`, so each value must be a string. Arrays are * rejected so REST saves never pass nested role lists into members_sanitize_role(). * * @since 3.2.22 * @access public * @param mixed $value Meta value from the database or REST request. * @return string */ function members_sanitize_access_role_meta_value( $value ) { if ( is_array( $value ) || ! is_string( $value ) || '' === $value ) { return ''; } $role = members_sanitize_role( $value ); return '' !== $role ? $role : ''; } /** * Sanitizes an array of `_members_access_role` values for REST and programmatic saves. * * @since 3.2.22 * @access public * @param mixed $roles Role slug list from a REST or form payload. * @return array */ function members_sanitize_access_role_meta_list( $roles ) { $sanitized = array(); if ( ! is_array( $roles ) ) { return $sanitized; } foreach ( $roles as $role ) { if ( is_string( $role ) && '' !== $role ) { $role = members_sanitize_role( $role ); if ( '' !== $role ) { $sanitized[] = $role; } } } return array_values( array_unique( $sanitized ) ); } /** * Prevents empty `_members_access_role` rows from being stored. * * @since 3.2.22 * @access public * @param null|bool $check Short-circuit return value. * @param int $object_id Post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. * @return null|bool */ function members_skip_empty_access_role_post_meta( $check, $object_id, $meta_key, $meta_value ) { if ( '_members_access_role' !== $meta_key || ( is_string( $meta_value ) && '' !== $meta_value ) ) { return $check; } return true; } add_filter( 'add_post_metadata', 'members_skip_empty_access_role_post_meta', 10, 4 ); add_filter( 'update_post_metadata', 'members_skip_empty_access_role_post_meta', 10, 4 ); /** * Returns access roles for REST/block editor reads without writing to the database. * * @since 3.2.22 * @access public * @param int $post_id Post ID. * @return array */ function members_get_post_roles_for_rest( $post_id ) { $roles = members_get_post_roles( $post_id ); if ( empty( $roles ) ) { $legacy = get_post_meta( $post_id, '_role', false ); if ( ! empty( $legacy ) ) { $roles = array(); foreach ( (array) $legacy as $role ) { if ( is_string( $role ) && '' !== $role ) { $roles[] = members_sanitize_role( $role ); } } $roles = array_values( array_unique( $roles ) ); } } return is_array( $roles ) ? $roles : array(); } /** * Conditional check to determine if a post has roles assigned to it. * * @since 2.0.0 * @access public * @param int $post_id * @return bool */ function members_has_post_roles( $post_id = '' ) { if ( ! $post_id ) $post_id = get_the_ID(); $roles = members_get_post_roles( $post_id ); return ! empty( $roles ); } /** * Whether the current user can manage content permissions for a specific post. * * The single definition of the "manager" policy used by the REST read strip, the REST * write gate, and the protected-posts result filter. Keep them in sync by changing it here. * * @since 3.2.25 * @access public * @param int $post_id Post ID. * @return bool */ function members_current_user_can_manage_post_content_permissions( $post_id ) { return current_user_can( 'restrict_content' ) && current_user_can( 'edit_post', $post_id ); } /** * Whether Content Permissions is enabled for a post type. * * @since 3.2.22 * @access public * @param string $post_type Post type slug. * @return bool */ function members_is_content_permissions_enabled_for_post_type( $post_type ) { if ( empty( $post_type ) ) { return false; } $type = get_post_type_object( $post_type ); if ( ! $type ) { return false; } // Attachments are off by default, but still honor the filter so a site can opt them in // (an early return here previously broke sites using members_enable_attachment_content_permissions). $enable = 'attachment' === $post_type ? false : $type->public; return apply_filters( "members_enable_{$post_type}_content_permissions", $enable ); } /** * Post types that support the Content Permissions UI and REST meta fields. * * @since 3.2.22 * @access public * @return array Post type slugs. */ function members_get_content_permissions_post_types() { $post_types = array(); foreach ( get_post_types( array(), 'names' ) as $post_type ) { if ( members_is_content_permissions_enabled_for_post_type( $post_type ) ) { $post_types[] = $post_type; } } return $post_types; } /** * Resolves the post for Content Permissions UI (classic meta box and block editor). * * @since 3.2.22 * @access public * @param \WP_Post|null $post Known post object, if available. * @return \WP_Post|null */ function members_get_post_for_content_permissions( $post = null ) { if ( $post instanceof \WP_Post ) { return $post; } $post = get_post(); if ( ! $post && ! empty( $_GET['post'] ) ) { $post = get_post( absint( $_GET['post'] ) ); } return $post instanceof \WP_Post ? $post : null; } /** * Adds a single role to a post's access roles. * * @since 1.0.0 * @access public * @param int $post_id * @param string $role * @return int|false */ function members_add_post_role( $post_id, $role ) { return add_post_meta( $post_id, '_members_access_role', $role, false ); } /** * Removes a single role from a post's access roles. * * @since 1.0.0 * @access public * @param int $post_id * @param string $role * @return bool */ function members_remove_post_role( $post_id, $role ) { return delete_post_meta( $post_id, '_members_access_role', $role ); } /** * Returns stored role slugs that are not registered WordPress roles (e.g. deleted custom roles). * * @since 3.2.22 * @access public * @param int $post_id Post ID. * @return array */ function members_get_orphan_post_roles( $post_id ) { global $wp_roles; $roles = members_get_post_roles( $post_id ); $orphans = array(); if ( empty( $roles ) || ! is_array( $roles ) ) { return $orphans; } foreach ( $roles as $role ) { if ( is_string( $role ) && '' !== $role && ! isset( $wp_roles->role_names[ $role ] ) ) { $orphans[] = members_sanitize_role( $role ); } } return array_values( array_unique( $orphans ) ); } /** * Sets a post's access roles given an array of roles. * * @since 1.0.0 * @access public * @param int $post_id * @param array $roles * @global object $wp_roles * @return void */ function members_set_post_roles( $post_id, $roles ) { global $wp_roles; // Get the current roles. $current_roles = get_post_meta( $post_id, '_members_access_role', false ); // Loop through new roles. foreach ( $roles as $role ) { // If new role is not already one of the current roles, add it. if ( ! in_array( $role, $current_roles ) ) members_add_post_role( $post_id, $role ); } // Loop through all WP roles. foreach ( $wp_roles->role_names as $role => $name ) { // If the WP role is one of the current roles but not a new role, remove it. if ( ! in_array( $role, $roles ) && in_array( $role, $current_roles ) ) members_remove_post_role( $post_id, $role ); } } /** * Deletes all of a post's access roles. * * @since 1.0.0 * @access public * @param int $post_id * @return bool */ function members_delete_post_roles( $post_id ) { return delete_post_meta( $post_id, '_members_access_role' ); } /** * Saves a post's Content Permissions (roles and custom error message). Shared by the classic * meta box and the block editor panel's REST route so both save exactly the same way. * * Callers are responsible for the capability check. * * @since 3.2.28 * @access public * @param int $post_id Post ID. * @param mixed $roles Role slugs chosen in the UI. * @param mixed $message Custom error message. * @return void */ function members_save_post_content_permissions( $post_id, $roles, $message ) { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return; } // Migrate any legacy `_role` meta first so it isn't lost or left behind. members_convert_old_post_meta( $post_id ); /* === Roles === */ // The list sanitizer drops non-string entries, so a crafted nested-array payload cannot // fatal members_sanitize_role() on PHP 8. // Only registered roles can be newly added; the UI never offers anything else. $wp_role_names = wp_roles()->role_names; $new_roles = array_values( array_intersect( members_sanitize_access_role_meta_list( $roles ), array_keys( $wp_role_names ) ) ); $current_roles = members_get_post_roles( $post_id ); // The UI only shows roles allowed by the members_wp_roles filter, so a stored role that was // hidden from it (registered but filtered out) would be silently deleted by // members_set_post_roles(). Carry those forward. Deleted custom roles (orphans) are already // retained by members_set_post_roles(), which only removes currently-registered roles. $displayed = array_keys( (array) apply_filters( 'members_wp_roles', $wp_role_names, $post ) ); foreach ( (array) $current_roles as $stored ) { if ( is_string( $stored ) && '' !== $stored && isset( $wp_role_names[ $stored ] ) && ! in_array( $stored, $displayed, true ) ) { $new_roles[] = members_sanitize_role( $stored ); } } // Set the merged role list (empty clears registered roles while leaving orphans intact). members_set_post_roles( $post_id, array_values( array_unique( $new_roles ) ) ); /* === Error Message === */ $old_message = members_get_post_access_message( $post_id ); $new_message = is_string( $message ) ? wp_kses_post( $message ) : ''; // If we don't have a new message but do have an old one, delete it. if ( '' === $new_message && $old_message ) members_delete_post_access_message( $post_id ); // If the new message doesn't match the old message, set it. else if ( $new_message !== $old_message ) members_set_post_access_message( $post_id, $new_message ); } /** * Adds required filters for the content permissions feature if it is active. * * @since 0.2.0 * @access public * @global object $wp_embed * @return void */ function members_enable_content_permissions() { global $wp_embed; // Only add filters if the content permissions feature is enabled and we're not in the admin. if ( members_content_permissions_enabled() && !is_admin() ) { // Filter the content and exerpts. add_filter( 'the_content', 'members_content_permissions_protect', 95 ); add_filter( 'get_the_excerpt', 'members_content_permissions_protect', 95 ); add_filter( 'the_excerpt', 'members_content_permissions_protect', 95 ); add_filter( 'the_content_feed', 'members_content_permissions_protect', 95 ); // Check comments against their own post, not the page showing them (e.g. Latest Comments block). add_filter( 'get_comment_text', 'members_content_permissions_protect_comment', 95, 2 ); add_filter( 'get_comment_excerpt', 'members_content_permissions_protect_comment', 95, 2 ); // The Latest Posts block's full-content mode renders posts without `the_content`. add_filter( 'register_block_type_args', 'members_protect_latest_posts_block', 10, 2 ); // Filter the comments template to make sure comments aren't shown to users without access. add_filter( 'comments_template', 'members_content_permissions_comments', 95 ); // Bricks Builder renders its pages without `the_content`. add_filter( 'bricks/render_with_bricks', 'members_bricks_render_with_bricks', 10, 2 ); // Use WP formatting filters on the post error message. add_filter( 'members_post_error_message', array( $wp_embed, 'run_shortcode' ), 5 ); add_filter( 'members_post_error_message', array( $wp_embed, 'autoembed' ), 5 ); add_filter( 'members_post_error_message', 'wptexturize', 10 ); add_filter( 'members_post_error_message', 'convert_smilies', 15 ); add_filter( 'members_post_error_message', 'convert_chars', 20 ); add_filter( 'members_post_error_message', 'wpautop', 25 ); add_filter( 'members_post_error_message', 'do_shortcode', 30 ); add_filter( 'members_post_error_message', 'shortcode_unautop', 35 ); } } /** * Before any output, marks the page as uncacheable when the main query shows content that differs * per visitor, so the no-cache headers can still be sent (output that's rendered later can only set * `DONOTCACHEPAGE`, which a CDN never sees). It looks for: * * - posts protected with Content Permissions, directly or through a protected parent; * - the Members access, logged-in/out, and login form shortcodes; * - blocks with Block Permissions, when that add-on is active. * * Covers single posts, archives, and feeds. In feeds, protected posts show everyone their error * message, a private feed shows everyone its message, and the access and logged-in shortcodes show * nothing, so only the remaining shortcodes and blocks are checked there, in the content and in * those messages. robots.txt, the favicon, sitemaps, and 404s are the same for everyone, so they're * skipped. * * @access public * @global \WP_Query $wp_query * @return void */ function members_do_not_cache_personal_content() { global $wp_query; if ( is_robots() || is_favicon() || is_404() || get_query_var( 'sitemap' ) || empty( $wp_query->posts ) ) { return; } $is_feed = is_feed(); $protects = members_content_permissions_enabled(); $check_roles = ! $is_feed && $protects; $check_blocks = class_exists( '\Members\BlockPermissions\Block', false ); $feed_message = $is_feed && members_is_private_feed() ? (string) members_get_setting( 'private_feed_error' ) : ''; $shortcodes = array( 'members_not_logged_in', 'members_login_form', 'login-form' ); if ( ! $is_feed ) { $shortcodes = array_merge( $shortcodes, array( 'members_access', 'access', 'members_logged_in', 'is_user_logged_in' ) ); } foreach ( $wp_query->posts as $post ) { if ( ! $post instanceof \WP_Post ) { continue; } $personal = $check_roles && members_post_or_ancestor_has_role_meta( $post->ID ); $content = members_get_content_with_synced_patterns( $post->post_content ); // The feed messages run shortcodes, so a login form in one makes the feed differ by login. if ( $is_feed ) { if ( $protects && members_post_or_ancestor_has_role_meta( $post->ID ) ) { $message = members_get_post_access_message( $post->ID ); $content .= "\n" . ( $message ? $message : members_get_setting( 'content_permissions_error' ) ); } $content .= "\n" . $feed_message; } foreach ( $shortcodes as $tag ) { $personal = $personal || has_shortcode( $content, $tag ); } // Only the types that show a block to some visitors and not others. $personal = $personal || ( $check_blocks && preg_match( '/"blockPermissionsCondition":"(=|!=)"/', $content ) && preg_match( '/"blockPermissionsType":"(user-status|role|cap)"/', $content ) ); if ( $personal ) { members_do_not_cache_page( 'content' ); return; } } } /** * Returns the content with the content of any synced patterns it uses appended, since the post * itself only stores a `` pointer to them. Follows nested patterns, * and skips any pattern already on the current path so a pattern that includes itself can't loop. * * @access public * @param string $content Post content. * @param array $seen Pattern IDs already on the current path. * @return string */ function members_get_content_with_synced_patterns( $content, $seen = array() ) { if ( ! str_contains( $content, '