diff --git a/includes/frontend/class-display.php b/includes/frontend/class-display.php index 8f9c4cd..454f966 100644 --- a/includes/frontend/class-display.php +++ b/includes/frontend/class-display.php @@ -565,6 +565,7 @@ public static function get_term_posts( $term ): \WP_Query { // Get all the posts for the current term excluding posts located in its child terms. $args = array( + 'post_status' => 'publish', 'posts_per_page' => -1, 'no_found_rows' => true, 'ignore_sticky_posts' => true, @@ -587,11 +588,15 @@ public static function get_term_posts( $term ): \WP_Query { $query = null; $cache_enabled = ! empty( self::get_cached_option( 'cache' ) ); $meta_key = null; + $version = Cache::get_content_version(); // Support caching to speed up retrieval. Cache::get_key() includes the current language. if ( $cache_enabled ) { $meta_key = Cache::get_key( $args ); - $query = Cache::get( $term->term_id, $meta_key ); + $cached = Cache::get( $term->term_id, $meta_key ); + if ( is_array( $cached ) && ( $cached['version'] ?? 0 ) === $version ) { + $query = $cached['query'] ?? null; + } } if ( empty( $query ) ) { @@ -599,7 +604,14 @@ public static function get_term_posts( $term ): \WP_Query { // Support caching to speed up retrieval. if ( $cache_enabled ) { - Cache::set( $term->term_id, $meta_key, $query ); + Cache::set( + $term->term_id, + $meta_key, + array( + 'version' => $version, + 'query' => $query, + ) + ); } } diff --git a/includes/frontend/class-media-handler.php b/includes/frontend/class-media-handler.php index 416621e..bbbd4bf 100644 --- a/includes/frontend/class-media-handler.php +++ b/includes/frontend/class-media-handler.php @@ -600,8 +600,8 @@ protected static function get_thumbnail_from_content_scan( \WP_Post $post, array ); } - // Skip content scanning for very large posts to prevent memory exhaustion. - if ( strlen( $post->post_content ) > 50000 ) { // 50KB limit. + // Skip oversized or locked content before scanning for images. + if ( strlen( $post->post_content ) > 50000 || post_password_required( $post ) ) { // 50KB limit. $post_content = ''; } else { /** @@ -660,7 +660,7 @@ protected static function get_thumbnail_from_content_scan( \WP_Post $post, array */ protected static function get_thumbnail_from_first_child( \WP_Post $post, array $args ): array { $dimensions = self::get_thumb_size( $args['size'] ); - $postimage = self::get_first_image( $post->ID, $dimensions[0], $dimensions[1] ); + $postimage = post_password_required( $post ) ? '' : self::get_first_image( $post->ID, $dimensions[0], $dimensions[1] ); if ( ! $postimage ) { return array( diff --git a/includes/frontend/class-related.php b/includes/frontend/class-related.php index cf40afd..f8d4a91 100644 --- a/includes/frontend/class-related.php +++ b/includes/frontend/class-related.php @@ -112,6 +112,7 @@ public static function get_related_articles_query( $args = array() ) { 'ignore_sticky_posts' => true, 'no_found_rows' => true, 'post_type' => 'wz_knowledgebase', + 'post_status' => 'publish', 'posts_per_page' => $args['numberposts'], 'post__not_in' => $exclude, 'orderby' => 'date', @@ -299,7 +300,7 @@ public static function get_related_articles( $args = array() ) { $output .= ''; - if ( $args['show_excerpt'] ) { + if ( $args['show_excerpt'] && ! post_password_required( $post_id ) ) { $excerpt = get_the_excerpt(); if ( empty( $excerpt ) ) { $content = get_post_field( 'post_content', $post_id ); diff --git a/includes/frontend/class-shortcodes.php b/includes/frontend/class-shortcodes.php index 8ed1440..9f29d61 100644 --- a/includes/frontend/class-shortcodes.php +++ b/includes/frontend/class-shortcodes.php @@ -266,7 +266,7 @@ public static function toc( $atts ): string { ); $post = get_post(); - if ( ! $post ) { + if ( ! $post || TOC::is_post_locked( $post ) ) { return ''; } diff --git a/includes/frontend/class-toc.php b/includes/frontend/class-toc.php index 4beb106..b3cc4c7 100644 --- a/includes/frontend/class-toc.php +++ b/includes/frontend/class-toc.php @@ -49,6 +49,26 @@ public function __construct() { } } + /** + * Check whether a post's content is locked behind a password for the current visitor. + * + * Editors previewing the post in the block editor still see the TOC. + * + * @since 3.2.1 + * + * @param \WP_Post $post Post object. + * @return bool True when the TOC must not be shown. + */ + public static function is_post_locked( \WP_Post $post ): bool { + if ( ! post_password_required( $post ) ) { + return false; + } + + $is_editor_preview = ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) && current_user_can( 'edit_post', $post->ID ); + + return ! $is_editor_preview; + } + /** * Filter callback: add anchor IDs to headings in article content. * diff --git a/includes/rest/class-rest-controller.php b/includes/rest/class-rest-controller.php index 4cef4f1..31f5ba5 100644 --- a/includes/rest/class-rest-controller.php +++ b/includes/rest/class-rest-controller.php @@ -13,6 +13,7 @@ use WP_REST_Response; use WP_REST_Server; use WebberZone\Knowledge_Base\Frontend\Related; +use WebberZone\Knowledge_Base\Util\Cache; use WebberZone\Knowledge_Base\Util\Hook_Registry; if ( ! defined( 'WPINC' ) ) { @@ -41,6 +42,16 @@ class REST_Controller { */ private const DEFAULT_TTL = 300; + /** + * Cache key format version. + * + * Changing it orphans entries cached by earlier releases, such as 3.2.0 responses + * that included password-protected content. + * + * @since 3.2.1 + */ + private const CACHE_KEY_VERSION = 'p3'; + /** * Constructor. */ @@ -460,6 +471,7 @@ public function get_knowledgebase_posts( WP_REST_Request $request ) { $query_args = array( 'post_type' => 'wz_knowledgebase', + 'post_status' => 'publish', 'ignore_sticky_posts' => true, 'no_found_rows' => false, 'posts_per_page' => $per_page, @@ -520,14 +532,16 @@ public function get_knowledgebase_posts( WP_REST_Request $request ) { $response->header( 'X-WP-Total', (string) (int) $query->found_posts ); $response->header( 'X-WP-TotalPages', (string) (int) $query->max_num_pages ); - $this->cache_set( - $cache_key, - array( - 'body' => $posts, - 'total' => (int) $query->found_posts, - 'pages' => (int) $query->max_num_pages, - ) - ); + if ( ! $this->has_password_protected_post( $query->posts ) ) { + $this->cache_set( + $cache_key, + array( + 'body' => $posts, + 'total' => (int) $query->found_posts, + 'pages' => (int) $query->max_num_pages, + ) + ); + } return $response; } @@ -558,6 +572,11 @@ public function get_single_knowledgebase_post( WP_REST_Request $request ) { ); } + // Password-protected responses depend on the requester's password cookie, so never cache them. + if ( '' !== $post->post_password ) { + return rest_ensure_response( $this->prepare_post_for_response( $post, true ) ); + } + $cache_key = $this->get_cache_key( 'kb_single', array( @@ -643,29 +662,14 @@ public function get_related_articles( WP_REST_Request $request ) { ); } - $cache_key = $this->get_cache_key( - 'related', - array( - 'post_id' => $post_id, - 'limit' => $limit, - ) - ); - - $cached = $this->cache_get( $cache_key ); - if ( false !== $cached ) { - return rest_ensure_response( $cached ); - } - + // Render per visitor because protected thumbnails vary with the password cookie. $related = Related::get_related_articles( array( - 'post_id' => $post_id, - 'limit' => $limit, - 'echo' => false, + 'post' => $post, + 'numberposts' => $limit, ) ); - $this->cache_set( $cache_key, $related ); - return rest_ensure_response( $related ); } @@ -742,7 +746,9 @@ public function get_search_results( WP_REST_Request $request ) { $results[] = $this->prepare_post_for_response( $post ); } - $this->cache_set( $cache_key, $results ); + if ( ! $this->has_password_protected_post( $wp_query->posts ) ) { + $this->cache_set( $cache_key, $results ); + } return rest_ensure_response( $results ); } @@ -1013,6 +1019,9 @@ private function get_post_list_args() { /** * Prepare KB post data. * + * Password-protected posts return an empty excerpt and content unless the + * requester has entered the post password using WordPress's password form. + * * @param \WP_Post $post Post object. * @param bool $include_content Include full content flag. * @return array @@ -1023,25 +1032,50 @@ private function prepare_post_for_response( $post, $include_content = false ) { return array(); } + $is_protected = '' !== $post->post_password; + $is_locked = $is_protected && post_password_required( $post ); + $data = array( 'id' => (int) $post->ID, 'title' => get_the_title( $post ), 'slug' => $post->post_name, - 'excerpt' => wp_trim_words( wp_strip_all_tags( $post->post_content ), 55 ), + 'excerpt' => $is_locked ? '' : wp_trim_words( wp_strip_all_tags( $post->post_content ), 55 ), 'permalink' => get_permalink( $post ), 'products' => $this->format_terms( get_the_terms( $post, 'wzkb_product' ) ), 'sections' => $this->format_terms( get_the_terms( $post, 'wzkb_category' ) ), 'date' => mysql2date( DATE_RFC3339, $post->post_date_gmt, false ), 'modified' => mysql2date( DATE_RFC3339, $post->post_modified_gmt, false ), + 'protected' => $is_protected, ); if ( $include_content ) { - $data['content'] = apply_filters( 'the_content', $post->post_content ); + $data['content'] = $is_locked ? '' : apply_filters( 'the_content', $post->post_content ); } return $data; } + /** + * Check whether any post in a list is password protected. + * + * Responses containing such posts vary with the requester's password cookie + * and must not be cached. + * + * @since 3.2.1 + * + * @param array $posts Post objects. + * @return bool + */ + private function has_password_protected_post( array $posts ): bool { + foreach ( $posts as $post ) { + if ( $post instanceof \WP_Post && '' !== $post->post_password ) { + return true; + } + } + + return false; + } + /** * Format terms list. * @@ -1100,8 +1134,9 @@ private function get_cache_key( $prefix, $context = array() ) { } return sprintf( - '%s_%d_%s', + '%s_%s_%d_%s', $prefix, + self::CACHE_KEY_VERSION, $this->get_cache_version(), empty( $context ) ? 'static' : md5( wp_json_encode( $context ) ) ); @@ -1113,7 +1148,7 @@ private function get_cache_key( $prefix, $context = array() ) { * @return int */ private function get_cache_version() { - return (int) get_option( 'wzkb_rest_cache_version', 1 ); + return Cache::get_content_version(); } /** @@ -1122,8 +1157,7 @@ private function get_cache_version() { * @return void */ public function bump_cache_version() { - $version = $this->get_cache_version(); - update_option( 'wzkb_rest_cache_version', $version + 1, false ); + Cache::bump_content_version(); } /** diff --git a/includes/util/class-cache.php b/includes/util/class-cache.php index 91523fb..3b54db5 100644 --- a/includes/util/class-cache.php +++ b/includes/util/class-cache.php @@ -139,6 +139,30 @@ public static function get_key( $attr ) { return $meta_key; } + /** + * Get the content version used to invalidate cached Knowledge Base output. + * + * Stored as an option so it persists without an object cache. It is bumped + * whenever an article is saved, changes status or is deleted, and when + * sections or products change. + * + * @since 3.2.1 + * + * @return int Content version. + */ + public static function get_content_version(): int { + return (int) get_option( 'wzkb_rest_cache_version', 1 ); + } + + /** + * Increment the content version, invalidating cached Knowledge Base output. + * + * @since 3.2.1 + */ + public static function bump_content_version(): void { + update_option( 'wzkb_rest_cache_version', self::get_content_version() + 1, false ); + } + /** * Get the timestamp meta key for a cache entry. * diff --git a/phpunit/tests/test-rest-password-protection.php b/phpunit/tests/test-rest-password-protection.php new file mode 100644 index 0000000..bd77663 --- /dev/null +++ b/phpunit/tests/test-rest-password-protection.php @@ -0,0 +1,229 @@ +post->create( + array( + 'post_type' => 'wz_knowledgebase', + 'post_status' => 'publish', + 'post_title' => 'Password regression article', + 'post_content' => 'wzkbpasswordmarker confidential body', + 'post_password' => 'LocalSecret!2026', + ) + ); + wp_set_current_user( 0 ); + $cookie_name = 'wp-postpass_' . COOKIEHASH; + $old_cookie = $_COOKIE[ $cookie_name ] ?? null; + $request = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase/' . $post_id ); + $list = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase' ); + $list->set_param( 'per_page', 50 ); + $search = new WP_REST_Request( 'GET', '/wzkb/v1/search' ); + $search->set_param( 'query', 'regression' ); + + try { + unset( $_COOKIE[ $cookie_name ] ); + $locked = rest_get_server()->dispatch( $request ); + $this->assertSame( 200, $locked->get_status() ); + $this->assertSame( '', $locked->get_data()['content'] ); + $this->assertSame( '', $locked->get_data()['excerpt'] ); + $this->assertTrue( $locked->get_data()['protected'] ); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + require_once ABSPATH . WPINC . '/class-phpass.php'; + $hasher = new PasswordHash( 8, true ); + $_COOKIE[ $cookie_name ] = $hasher->HashPassword( 'LocalSecret!2026' ); + $unlocked = rest_get_server()->dispatch( $request ); + $this->assertStringContainsString( 'wzkbpasswordmarker', $unlocked->get_data()['content'] ); + foreach ( array( $list, $search ) as $collection ) { + $response = rest_get_server()->dispatch( $collection ); + $items = array_column( $response->get_data(), null, 'id' ); + $this->assertStringContainsString( 'wzkbpasswordmarker', $items[ $post_id ]['excerpt'] ); + } + + unset( $_COOKIE[ $cookie_name ] ); + $this->assertSame( '', rest_get_server()->dispatch( $request )->get_data()['content'] ); + foreach ( array( $list, $search ) as $collection ) { + $response = rest_get_server()->dispatch( $collection ); + $items = array_column( $response->get_data(), null, 'id' ); + $this->assertSame( '', $items[ $post_id ]['excerpt'] ); + } + } finally { + if ( null === $old_cookie ) { + unset( $_COOKIE[ $cookie_name ] ); + } else { + $_COOKIE[ $cookie_name ] = $old_cookie; + } + } + } + + public function test_adding_a_password_invalidates_open_responses() { + $post_id = self::factory()->post->create( + array( + 'post_type' => 'wz_knowledgebase', + 'post_status' => 'publish', + 'post_title' => 'Password transition article', + 'post_content' => 'wzkbtransitionmarker confidential body', + ) + ); + wp_set_current_user( 0 ); + $single = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase/' . $post_id ); + $list = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase' ); + $list->set_param( 'search', 'transition' ); + $this->assertStringContainsString( 'wzkbtransitionmarker', wp_json_encode( rest_get_server()->dispatch( $single )->get_data() ) ); + $this->assertStringContainsString( 'wzkbtransitionmarker', wp_json_encode( rest_get_server()->dispatch( $list )->get_data() ) ); + + wp_update_post( array( 'ID' => $post_id, 'post_password' => 'new-password' ) ); + + $this->assertSame( '', rest_get_server()->dispatch( $single )->get_data()['content'] ); + $this->assertStringNotContainsString( 'wzkbtransitionmarker', wp_json_encode( rest_get_server()->dispatch( $list )->get_data() ) ); + } + public function test_public_collection_does_not_cache_private_articles() { + $post_id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'private', 'post_content' => 'wzkbprivatemarker' ) ); + $request = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase' ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + rest_get_server()->dispatch( $request ); + wp_set_current_user( 0 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertNotContains( $post_id, array_column( $response->get_data(), 'id' ) ); + } + + public function test_related_rest_does_not_cache_unlocked_body_images() { + $source = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish' ) ); + $protected = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_password' => 'secret', 'post_content' => '' ) ); + $request = new WP_REST_Request( 'GET', '/wzkb/v1/related' ); + $request->set_param( 'post_id', $source ); + $request->set_param( 'limit', 5 ); + wp_set_current_user( 0 ); + require_once ABSPATH . WPINC . '/class-phpass.php'; + $hasher = new PasswordHash( 8, true ); + $cookie_name = 'wp-postpass_' . COOKIEHASH; + $old_cookie = $_COOKIE[ $cookie_name ] ?? null; + try { + $_COOKIE[ $cookie_name ] = $hasher->HashPassword( 'secret' ); + $unlocked = rest_get_server()->dispatch( $request ); + $this->assertStringContainsString( 'wzkbprotectedimage', $unlocked->get_data() ); + unset( $_COOKIE[ $cookie_name ] ); + $this->assertStringNotContainsString( 'wzkbprotectedimage', rest_get_server()->dispatch( $request )->get_data() ); + } finally { + if ( null === $old_cookie ) { + unset( $_COOKIE[ $cookie_name ] ); + } else { + $_COOKIE[ $cookie_name ] = $old_cookie; + } + } + } + + public function test_route_filters_and_taxonomy_parameters_cannot_unlock_content() { + $post_id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_content' => 'wzkbparammarker', 'post_password' => 'secret' ) ); + $section = self::factory()->term->create( array( 'taxonomy' => 'wzkb_category' ) ); + $product = self::factory()->term->create( array( 'taxonomy' => 'wzkb_product' ) ); + wp_set_object_terms( $post_id, array( $section ), 'wzkb_category' ); + wp_set_object_terms( $post_id, array( $product ), 'wzkb_product' ); + add_filter( 'wzkb_rest_route_permission', '__return_true' ); + foreach ( array( 0, self::factory()->user->create( array( 'role' => 'subscriber' ) ), self::factory()->user->create( array( 'role' => 'contributor' ) ) ) as $user_id ) { + wp_set_current_user( $user_id ); + foreach ( array( '', 'en', 'fr' ) as $language ) { + $request = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase/' . $post_id ); + $request->set_param( 'lang', $language ); + $request->set_param( 'context', 'edit' ); + $request->set_param( 'password', 'secret' ); + $this->assertSame( '', rest_get_server()->dispatch( $request )->get_data()['content'] ); + $list = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase' ); + $list->set_param( 'product', $product ); + $list->set_param( 'section', $section ); + $list->set_param( 'per_page', 1 ); + $list->set_param( 'page', 1 ); + $list->set_param( 'lang', $language ); + $response = rest_get_server()->dispatch( $list ); + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( '', $response->get_data()[0]['excerpt'] ); + $this->assertSame( '1', $response->get_headers()['X-WP-Total'] ); + } + } + } + + public function test_password_removal_restores_content() { + $post_id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_content' => 'wzkbremovedpassword', 'post_password' => 'secret' ) ); + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wzkb/v1/knowledgebase/' . $post_id ); + $this->assertSame( '', rest_get_server()->dispatch( $request )->get_data()['content'] ); + wp_update_post( array( 'ID' => $post_id, 'post_password' => '' ) ); + $this->assertStringContainsString( 'wzkbremovedpassword', rest_get_server()->dispatch( $request )->get_data()['content'] ); + } + + public function test_attached_image_fallback_does_not_expose_locked_images() { + $post_id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_password' => 'secret' ) ); + $attachment = self::factory()->post->create( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_parent' => $post_id, 'post_mime_type' => 'image/jpeg' ) ); + update_post_meta( $attachment, '_wp_attached_file', 'wzkbsecretattachment.jpg' ); + wp_set_current_user( 0 ); + $html = \WebberZone\Knowledge_Base\Frontend\Media_Handler::get_the_post_thumbnail( array( 'post' => $post_id ) ); + $this->assertStringNotContainsString( 'wzkbsecretattachment', $html ); + wp_update_post( array( 'ID' => $post_id, 'post_password' => '' ) ); + $html = \WebberZone\Knowledge_Base\Frontend\Media_Handler::get_the_post_thumbnail( array( 'post' => $post_id ) ); + $this->assertStringContainsString( 'wzkbsecretattachment', $html ); + } + + public function test_section_query_refreshes_when_an_article_gains_a_password() { + $section = self::factory()->term->create( array( 'taxonomy' => 'wzkb_category' ) ); + $id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish', 'post_excerpt' => 'wzkbstalesectionmarker' ) ); + wp_set_object_terms( $id, array( $section ), 'wzkb_category' ); + wp_set_current_user( 0 ); + $property = new ReflectionProperty( \WebberZone\Knowledge_Base\Frontend\Display::class, 'options_cache' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $previous = $property->getValue(); + $property->setValue( null, array( 'cache' => true ) ); + try { + $term = get_term( $section ); + $query = \WebberZone\Knowledge_Base\Frontend\Display::get_term_posts( $term ); + $this->assertSame( '', $query->posts[0]->post_password ); + wp_update_post( array( 'ID' => $id, 'post_password' => 'secret' ) ); + $query = \WebberZone\Knowledge_Base\Frontend\Display::get_term_posts( $term ); + $this->assertSame( 'secret', $query->posts[0]->post_password ); + $this->assertStringNotContainsString( 'wzkbstalesectionmarker', get_the_excerpt( $query->posts[0] ) ); + } finally { + $property->setValue( null, $previous ); + } + } + + public function test_section_query_cache_survives_a_new_request_without_an_object_cache() { + global $wpdb; + + $section = self::factory()->term->create( array( 'taxonomy' => 'wzkb_category' ) ); + $id = self::factory()->post->create( array( 'post_type' => 'wz_knowledgebase', 'post_status' => 'publish' ) ); + wp_set_object_terms( $id, array( $section ), 'wzkb_category' ); + $property = new ReflectionProperty( \WebberZone\Knowledge_Base\Frontend\Display::class, 'options_cache' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $previous = $property->getValue(); + $property->setValue( null, array( 'cache' => true ) ); + $meta_ids = static function () use ( $wpdb, $section ) { + return $wpdb->get_col( $wpdb->prepare( "SELECT meta_id FROM {$wpdb->termmeta} WHERE term_id = %d AND meta_key LIKE %s ORDER BY meta_id", $section, '\_wzkb\_cache\_%' ) ); + }; + try { + $term = get_term( $section ); + \WebberZone\Knowledge_Base\Frontend\Display::get_term_posts( $term ); + $first = $meta_ids(); + $this->assertNotEmpty( $first ); + + // A new request on a site without a persistent object cache starts with an empty object cache. + wp_cache_flush(); + \WebberZone\Knowledge_Base\Frontend\Display::get_term_posts( $term ); + $this->assertSame( $first, $meta_ids() ); + } finally { + $property->setValue( null, $previous ); + } + } + +} diff --git a/readme.txt b/readme.txt index a50a077..20d691d 100644 --- a/readme.txt +++ b/readme.txt @@ -195,6 +195,18 @@ Release date: TBD * [Pro] Reused cached Ask the Docs answers for reworded questions until their source articles change. +**Security** + +* The `/wzkb/v1/knowledgebase` and `/wzkb/v1/search` REST endpoints returned the content and excerpt of password-protected articles without the password. +* The `[kbtoc]` shortcode showed the headings of password-protected articles before the password was entered. +* Thumbnails could use an image from inside, or attached to, a password-protected article. +* Knowledge base listings and the `/wzkb/v1/knowledgebase` REST endpoint could show private articles to visitors from a cached copy. +* Cached knowledge base listings kept showing an article's excerpt after the article was password-protected. + +**Fixed** + +* The `/wzkb/v1/related` REST endpoint ignored its `post_id` and `limit` parameters. + = 3.2.0 = Release date: 7 October 2026 @@ -219,6 +231,7 @@ Release post: https://webberzone.com/announcements/knowledge-base-v3-2/ * The Knowledge Base search results page size overrode the REST search endpoint's `limit` parameter. * Knowledge Base search forms showed duplicate suggestions when Better Search live search was also active. +* [Pro] Help widget searches returned no articles with Better Search active, and the search box lost focus while typing. * Text in the Knowledge Base Alerts block could not be selected reliably in the editor. * Knowledge Base Alerts blocks without an alignment saved an invalid `text-align: none` inline style. * Settings repeater button labels used the wrong translation domain. @@ -230,4 +243,4 @@ For the changelog of earlier versions, please refer to the [releases page on Git == Upgrade Notice == = 3.2.1 = -Faster Pro Ask the Docs answers with a choice of model and a fallback provider, and better answer caching. Pro GitHub imports preserve image captions. +Security release. Stops password-protected and private articles leaking through the REST API, listings, tables of contents and thumbnails. Also brings faster Pro Ask the Docs answers with model choice, a fallback provider and better caching, and keeps captions in Pro GitHub imports.