diff --git a/README.md b/README.md index e0f1283..1de0610 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ FloAuth is a WordPress plugin for bridging FloMembers membership management and * Extranet (pages restricted to logged-in users only, **optional**) * Restricts access to extranet page and its children * Removes extranet pages from search results + * Optional: restrict **posts** by category tree (parent categories plus subcategories), enabled only via filters in `functions.php` — same capability rules as pages; restricted posts are omitted from the **main** blog home, post archives (category, tag, author, date), **search**, and **RSS/Atom feeds** for visitors without access (singular URLs still redirect; some theme blocks/widgets use separate queries and are not covered) * Hides WordPress toolbar from Subscribers ## Installation @@ -114,8 +115,27 @@ function custom_restrict_extranet_pages_capability( $capability ) { } add_filter( 'floauth_restrict_extranet_pages_capability', 'custom_restrict_extranet_pages_capability' ); ``` + More info on WordPress [Roles and Capabilities](https://wordpress.org/support/article/roles-and-capabilities/) +### Restrict posts by category + +By default, only the **Extranet path** setting (pages and child pages) applies. To also protect **standard WordPress posts** that belong to a category and its subcategories, enable the feature and return the **root** category term IDs (numeric `term_id` values from **Posts → Categories** in the admin, or from the REST API / database). + +Restrictions use the same capability as pages (`floauth_restrict_extranet_pages_capability`, default `read`). Visitors without that access are **redirected** away from singular posts in those categories and from **category archive** URLs for those terms. They also do **not** see those posts in the **main** blog/feed listing queries: home, typical post archives, **search**, and **RSS/Atom**. Custom blocks or widgets that run their own `WP_Query` may still list titles unless the theme adjusts them. + +```php +add_filter( 'floauth_extranet_restrict_posts_by_category', '__return_true' ); + +function my_site_floauth_extranet_restricted_category_ids() { + // One or more parent category term IDs; all descendant categories are included. + return array( 12, 34 ); +} +add_filter( 'floauth_extranet_restricted_category_ids', 'my_site_floauth_extranet_restricted_category_ids' ); +``` + +This does not add fields to the FloAuth settings screen; it keeps the UI unchanged for other sites. + ### Keep WordPress toolbar always visible ```php diff --git a/floauth.php b/floauth.php index f935181..06ce4eb 100755 --- a/floauth.php +++ b/floauth.php @@ -1,7 +1,7 @@ 0 ) { + $normalized[] = $id; + } + } + + return array_values( array_unique( $normalized ) ); +} + +/** + * Category term IDs that restrict posts (roots plus all descendants). + * + * @return int[] + */ +function floauth_get_extranet_restricted_category_term_ids() { + if ( ! floauth_extranet_restrict_posts_by_category_enabled() ) { + delete_transient( 'floauth_extranet_restricted_category_term_ids' ); + return array(); + } + $roots = floauth_get_extranet_restricted_category_root_ids(); + if ( empty( $roots ) ) { + delete_transient( 'floauth_extranet_restricted_category_term_ids' ); + return array(); + } + + $roots_hash = md5( wp_json_encode( $roots ) ); + $cached = get_transient( 'floauth_extranet_restricted_category_term_ids' ); + if ( + is_array( $cached ) + && isset( $cached['roots_hash'], $cached['term_ids'] ) + && $roots_hash === $cached['roots_hash'] + && is_array( $cached['term_ids'] ) + ) { + return floauth_extranet_normalize_positive_ids( $cached['term_ids'] ); + } + + $term_parent_map = get_terms( + array( + 'taxonomy' => 'category', + 'hide_empty' => false, + 'fields' => 'id=>parent', + ) + ); + if ( is_wp_error( $term_parent_map ) || ! is_array( $term_parent_map ) ) { + return array(); + } + + $children_by_parent = array(); + foreach ( $term_parent_map as $term_id => $parent_id ) { + $term_id = (int) $term_id; + $parent_id = (int) $parent_id; + if ( ! isset( $children_by_parent[ $parent_id ] ) ) { + $children_by_parent[ $parent_id ] = array(); + } + $children_by_parent[ $parent_id ][] = $term_id; + } + + $pending = array(); + $seen = array(); + $term_ids = array(); + foreach ( $roots as $root_id ) { + if ( isset( $term_parent_map[ (string) $root_id ] ) || isset( $term_parent_map[ $root_id ] ) ) { + $pending[] = (int) $root_id; + } + } + + while ( ! empty( $pending ) ) { + $current = array_pop( $pending ); + if ( isset( $seen[ $current ] ) ) { + continue; + } + $seen[ $current ] = true; + $term_ids[] = $current; + if ( isset( $children_by_parent[ $current ] ) ) { + foreach ( $children_by_parent[ $current ] as $child_id ) { + if ( ! isset( $seen[ $child_id ] ) ) { + $pending[] = (int) $child_id; + } + } + } + } + + $term_ids = floauth_extranet_normalize_positive_ids( $term_ids ); + set_transient( + 'floauth_extranet_restricted_category_term_ids', + array( + 'roots_hash' => $roots_hash, + 'term_ids' => $term_ids, + ), + HOUR_IN_SECONDS + ); + return $term_ids; +} + +/** + * Redirect when extranet content is blocked for the current user. + * + * @return void + */ +function floauth_extranet_block_redirect() { + wp_safe_redirect( apply_filters( 'floauth_restrict_extranet_block_redirect', home_url( '/' ) ) ); + exit(); +} + +/** + * Merge a tax_query clause with an existing query tax_query using AND. + * + * @param array $existing Existing tax_query array. + * @param array $clause New clause. + * @return array + */ +function floauth_extranet_merge_tax_query( $existing, $clause ) { + if ( empty( $existing ) || ! is_array( $existing ) ) { + return array( $clause ); + } + $clauses = array(); + foreach ( $existing as $key => $value ) { + if ( 'relation' === $key ) { + continue; + } + if ( is_array( $value ) ) { + $clauses[] = $value; + } + } + return array_merge( + array( 'relation' => 'AND' ), + $clauses, + array( $clause ) + ); +} + +/** + * Whether the current user may see extranet content (pages and optional category posts). + * + * @return bool + */ +function floauth_extranet_user_can_read_extranet() { + $capability = apply_filters( 'floauth_restrict_extranet_pages_capability', 'read' ); + return is_user_logged_in() && current_user_can( $capability ); +} + +/** + * Whether this is a main front-end listing/feed/search query where restricted posts should be omitted. + * + * Skips singular queries so template_redirect can still run for blocked single posts. + * + * @param WP_Query $query Query instance. + * @return bool + */ +function floauth_extranet_query_lists_posts_for_hiding( $query ) { + if ( is_admin() || ! $query->is_main_query() ) { + return false; + } + if ( $query->is_singular() ) { + return false; + } + if ( $query->is_search() || $query->is_feed() ) { + return true; + } + if ( $query->is_home() || $query->is_post_type_archive( 'post' ) ) { + return true; + } + if ( $query->is_category() || $query->is_tag() || $query->is_author() || $query->is_date() ) { + return true; + } + return false; +} + +/** + * Whether the query includes the post post type (default blog queries do). + * + * @param WP_Query $query Query instance. + * @return bool + */ +function floauth_extranet_query_includes_post_type_post( $query ) { + $post_type = $query->get( 'post_type' ); + if ( empty( $post_type ) ) { + return true; + } + if ( 'post' === $post_type ) { + return true; + } + if ( is_array( $post_type ) && in_array( 'post', $post_type, true ) ) { + return true; + } + return false; +} + +/** + * Remove extranet path and its children from search results if user has no rights. + * Optionally exclude posts in restricted categories from search, listings, and feeds (see filters). * * Capability can be changed with filter "floauth_restrict_extranet_pages_capability" * Capability defaults to "read", also logged-in users with no role have no access @@ -15,31 +240,60 @@ * @return WP_Query */ function floauth_filter_pre_get_posts( $query ) { - if ( $query->is_search ) { - $capability = apply_filters( 'floauth_restrict_extranet_pages_capability', 'read' ); - if ( ! is_user_logged_in() || ! current_user_can( $capability ) ) { - $restricted_post_id = (int) floauth_get_extranet_post_id(); - if ( 0 !== $restricted_post_id ) { - $children = get_pages( - array( - 'child_of' => $restricted_post_id, - ) - ); - $restricted_ids = array(); - $restricted_ids[] = $restricted_post_id; - foreach ( $children as $child ) { - $restricted_ids[] = $child->ID; - } - $query->set( 'post__not_in', $restricted_ids ); + if ( is_admin() || ! $query->is_main_query() ) { + return $query; + } + if ( floauth_extranet_user_can_read_extranet() ) { + return $query; + } + + if ( $query->is_search() ) { + $restricted_page_ids = array(); + $restricted_post_id = (int) floauth_get_extranet_post_id(); + if ( 0 !== $restricted_post_id ) { + $children = get_pages( + array( + 'child_of' => $restricted_post_id, + ) + ); + $restricted_page_ids[] = $restricted_post_id; + foreach ( $children as $child ) { + $restricted_page_ids[] = $child->ID; } } + if ( ! empty( $restricted_page_ids ) ) { + $existing_not_in = floauth_extranet_normalize_positive_ids( $query->get( 'post__not_in' ) ); + $merged_not_in = array_merge( $existing_not_in, $restricted_page_ids ); + $unique_not_in = floauth_extranet_normalize_positive_ids( $merged_not_in ); + $query->set( 'post__not_in', $unique_not_in ); + } } + + $category_term_ids = floauth_get_extranet_restricted_category_term_ids(); + if ( + ! empty( $category_term_ids ) + && floauth_extranet_query_lists_posts_for_hiding( $query ) + && floauth_extranet_query_includes_post_type_post( $query ) + ) { + $extranet_tax = array( + 'taxonomy' => 'category', + 'field' => 'term_id', + 'terms' => $category_term_ids, + 'operator' => 'NOT IN', + ); + $query->set( + 'tax_query', + floauth_extranet_merge_tax_query( $query->get( 'tax_query' ), $extranet_tax ) + ); + } + return $query; } add_filter( 'pre_get_posts', 'floauth_filter_pre_get_posts' ); /** - * Disable access to extranet path and its children if user has no rights + * Disable access to extranet path and its children if user has no rights. + * Optionally restrict singular posts and category archives by category tree (see filters). * * Capability can be changed with filter "floauth_restrict_extranet_pages_capability" * Capability defaults to "read", also logged-in users with no role have no access @@ -47,20 +301,40 @@ function floauth_filter_pre_get_posts( $query ) { * @return void */ function floauth_block_extranet_pages() { - $capability = apply_filters( 'floauth_restrict_extranet_pages_capability', 'read' ); - if ( ! is_user_logged_in() || ! current_user_can( $capability ) ) { - if ( ! is_search() ) { - $restricted_post_id = (int) floauth_get_extranet_post_id(); - if ( 0 !== $restricted_post_id ) { - $current_post_id = get_the_ID(); - $ancestors = get_post_ancestors( $current_post_id ); - if ( $restricted_post_id === $current_post_id || in_array( $restricted_post_id, $ancestors, true ) ) { - wp_safe_redirect( apply_filters( 'floauth_restrict_extranet_block_redirect', home_url( '/' ) ) ); - exit(); - } + if ( floauth_extranet_user_can_read_extranet() ) { + return; + } + if ( is_search() ) { + return; + } + + $restricted_post_id = (int) floauth_get_extranet_post_id(); + if ( 0 !== $restricted_post_id ) { + $current_post_id = get_the_ID(); + if ( $current_post_id ) { + $ancestors = get_post_ancestors( $current_post_id ); + if ( $restricted_post_id === $current_post_id || in_array( $restricted_post_id, $ancestors, true ) ) { + floauth_extranet_block_redirect(); } } } + + $term_ids = floauth_get_extranet_restricted_category_term_ids(); + if ( empty( $term_ids ) ) { + return; + } + + if ( is_singular( 'post' ) ) { + $post = get_queried_object(); + if ( $post instanceof WP_Post && has_category( $term_ids, $post ) ) { + floauth_extranet_block_redirect(); + } + } elseif ( is_category() ) { + $term = get_queried_object(); + if ( $term instanceof WP_Term && 'category' === $term->taxonomy && in_array( (int) $term->term_id, $term_ids, true ) ) { + floauth_extranet_block_redirect(); + } + } } add_action( 'template_redirect', 'floauth_block_extranet_pages' ); @@ -97,3 +371,15 @@ function floauth_clear_extranet_transient( $old_value, $new_value ) { delete_transient( 'floauth_extranet_post_id' ); } add_action( 'update_option_floauth_extranet_path', 'floauth_clear_extranet_transient', 10, 2 ); + +/** + * Clear restricted category term IDs transient. + * + * @return void + */ +function floauth_clear_extranet_restricted_category_term_ids_transient() { + delete_transient( 'floauth_extranet_restricted_category_term_ids' ); +} +add_action( 'created_category', 'floauth_clear_extranet_restricted_category_term_ids_transient' ); +add_action( 'edited_category', 'floauth_clear_extranet_restricted_category_term_ids_transient' ); +add_action( 'delete_category', 'floauth_clear_extranet_restricted_category_term_ids_transient' );