Changeset 8143
- Timestamp:
- 10/26/2026 06:35:28 AM (6 hours ago)
- Location:
- branches/2.6
- Files:
-
- 3 edited
-
CHANGELOG.md (modified) (1 diff)
-
src/includes/extend/buddypress/activity.php (modified) (3 diffs)
-
tests/phpunit/testcases/extend/buddypress/activity.php (modified) (4 diffs)
Legend:
- Unmodified
- Added
- Removed
-
branches/2.6/CHANGELOG.md
r8141 r8143 20 20 ### Security 21 21 22 - Preserved inherited password restrictions in BuddyPress activity queries. 22 23 - Prevented password-protected forum content from influencing search results. 23 24 - Required permission to manage both forums before moving a topic between them. -
branches/2.6/src/includes/extend/buddypress/activity.php
r8113 r8143 286 286 */ 287 287 private function get_password_restricted_post_ids() { 288 $bbp_db = bbp_db(); 288 289 $post_types = bbp_get_post_types(); 289 290 $post_ids = array_fill_keys( $post_types, array() ); 290 $protected = get_posts( 291 array( 292 'has_password' => true, 293 'no_found_rows' => true, 294 'numberposts' => -1, 295 'orderby' => 'none', 296 'post_status' => 'any', 297 'post_type' => $post_types, 298 'suppress_filters' => true, 299 'update_post_meta_cache' => false, 300 'update_post_term_cache' => false, 301 ) 302 ); 303 304 foreach ( $protected as $post ) { 305 if ( post_password_required( $post ) ) { 291 $types = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) ); 292 $key = md5( serialize( array( $bbp_db->posts, $post_types ) ) ); 293 $cache_key = 'bbp_activity_password_ids:' . $key . ':' . wp_cache_get_last_changed( 'bbpress_posts' ); 294 $protected = wp_cache_get( $cache_key, 'bbpress_posts' ); 295 296 if ( false === $protected ) { 297 // Padded collations can treat whitespace passwords as empty strings. 298 $protected = wp_parse_id_list( 299 $bbp_db->get_col( $bbp_db->prepare( "SELECT ID FROM {$bbp_db->posts} WHERE post_type IN ({$types}) AND CHAR_LENGTH( post_password ) > 0", $post_types ) ) 300 ); 301 302 // Do not retain a failed lookup. 303 if ( '' === $bbp_db->last_error ) { 304 wp_cache_set( $cache_key, $protected, 'bbpress_posts' ); 305 } 306 } 307 308 // Cache posts in bulk, but check the current password cookie every time. 309 _prime_post_caches( $protected, false, false ); 310 foreach ( $protected as $post_id ) { 311 $post = get_post( $post_id ); 312 if ( ! empty( $post ) && post_password_required( $post ) ) { 306 313 $post_ids[ $post->post_type ][] = (int) $post->ID; 307 314 } … … 310 317 // A password on a forum also protects every descendant forum. 311 318 $forum_type = bbp_get_forum_post_type(); 312 $parents = $post_ids[ $forum_type ]; 319 $parents = bbp_forum_query_all_descendant_ids( $post_ids[ $forum_type ] ); 320 $post_ids[ $forum_type ] = $parents; 313 321 $seen = array_fill_keys( $parents, true ); 314 322 $index = 0; … … 317 325 ++$index; 318 326 327 // Preserve the existing child-ID filter, including synthetic children. 328 $children = array(); 319 329 foreach ( bbp_forum_query_subforum_ids( $parent_id ) as $forum_id ) { 320 330 $forum_id = (int) $forum_id; 331 if ( ( $forum_id > 0 ) && ! isset( $seen[ $forum_id ] ) ) { 332 $children[] = $forum_id; 333 } 334 } 335 336 if ( empty( $children ) ) { 337 continue; 338 } 339 340 // Filter-added forums can also have non-countable descendants. 341 foreach ( bbp_forum_query_all_descendant_ids( $children ) as $forum_id ) { 321 342 if ( ! isset( $seen[ $forum_id ] ) ) { 322 343 $seen[ $forum_id ] = true; -
branches/2.6/tests/phpunit/testcases/extend/buddypress/activity.php
r8113 r8143 9 9 */ 10 10 class BBP_Tests_Extend_BuddyPress_Activity extends BBP_UnitTestCase { 11 12 /** 13 * Password representations and forum states that activity queries must cover. 14 * 15 * @return array[] Password and ancestor-state cases. 16 */ 17 public function password_activity_query_cases() { 18 $cases = array( 19 'draft ancestor' => array( 'publish', 'draft', 'parent', 'forum-secret' ), 20 'pending ancestor' => array( 'publish', 'pending', 'parent', 'forum-secret' ), 21 'trashed ancestor' => array( 'publish', 'trash', 'parent', 'forum-secret' ), 22 'auto-draft ancestor' => array( 'publish', 'auto-draft', 'parent', 'forum-secret' ), 23 'draft password root' => array( 'draft', 'publish', 'parent', 'forum-secret' ), 24 'trashed password root' => array( 'trash', 'publish', 'parent', 'forum-secret' ), 25 'auto-draft password root' => array( 'auto-draft', 'publish', 'parent', 'forum-secret' ), 26 'forum spaces' => array( 'publish', 'publish', 'parent', ' ' ), 27 'topic spaces' => array( 'publish', 'publish', 'topic', ' ' ), 28 'reply spaces' => array( 'publish', 'publish', 'reply', ' ' ), 29 'forum tab' => array( 'publish', 'publish', 'parent', "\t" ), 30 'zero password' => array( 'publish', 'publish', 'parent', '0' ), 31 ); 32 33 $result = array(); 34 foreach ( array( false, true ) as $group_activity ) { 35 foreach ( $cases as $name => $case ) { 36 $result[ ( $group_activity ? 'group ' : 'sitewide ' ) . $name ] = array_merge( $case, array( $group_activity ) ); 37 } 38 } 39 40 return $result; 41 } 42 43 /** 44 * Existing activity obeys passwords across collection and direct reads. 45 * 46 * @dataProvider password_activity_query_cases 47 * 48 * @param string $parent_status Password-root status. 49 * @param string $middle_status Intermediate forum status. 50 * @param string $target Post receiving the password. 51 * @param string $password Password representation. 52 * @param bool $group_activity Whether to use real group activity mapping. 53 */ 54 public function test_activity_password_queries_cover_descendants_and_whitespace( $parent_status, $middle_status, $target, $password, $group_activity ) { 55 global $wpdb; 56 57 $parent = $this->factory->forum->create(); 58 $middle = $this->factory->forum->create( array( 'post_parent' => $parent ) ); 59 $tree = $this->create_password_activity_tree( $middle, $group_activity ); 60 $public = $this->create_password_activity_tree( 0, $group_activity ); 61 $ids = array_merge( $tree['activities'], $public['activities'] ); 62 63 wp_update_post( array( 'ID' => $parent, 'post_status' => $parent_status ) ); 64 wp_update_post( array( 'ID' => $middle, 'post_status' => $middle_status ) ); 65 $post_id = ( 'parent' === $target ) ? $parent : $tree[ $target ]; 66 67 // Model existing rows retained by an import or a direct database update. 68 $this->assertSame( 1, $wpdb->update( $wpdb->posts, array( 'post_password' => $password ), array( 'ID' => $post_id ) ) ); 69 clean_post_cache( $post_id ); 70 $this->assertSame( $password, get_post_field( 'post_password', $post_id ) ); 71 72 $protected_ids = ( '0' === $password ) ? array() : $tree['activities']; 73 if ( 'reply' === $target ) { 74 $protected_ids = array( $tree['activities'][1] ); 75 } 76 $visible_ids = array_values( array_diff( $ids, $protected_ids ) ); 77 $viewers = array( 0 ); 78 foreach ( array( '', bbp_get_participant_role(), bbp_get_moderator_role(), bbp_get_keymaster_role() ) as $role ) { 79 $viewer = $this->factory->user->create(); 80 get_userdata( $viewer )->set_role( '' ); 81 if ( '' !== $role ) { 82 bbp_set_user_role( $viewer, $role ); 83 } 84 $viewers[] = $viewer; 85 } 86 87 $route = '/' . bp_rest_namespace() . '/' . bp_rest_version() . '/activity'; 88 $request = new WP_REST_Request( 'GET', $route ); 89 $old_user = get_current_user_id(); 90 $cookie = 'wp-postpass_' . COOKIEHASH; 91 $old_cookie = isset( $_COOKIE[ $cookie ] ) ? $_COOKIE[ $cookie ] : null; 92 $request->set_param( 'include', $ids ); 93 unset( $_COOKIE[ $cookie ] ); 94 95 try { 96 foreach ( $viewers as $viewer ) { 97 wp_set_current_user( $viewer ); 98 $this->assertSame( '0' !== $password, post_password_required( $post_id ) ); 99 $activity = bp_activity_get( array( 'in' => $ids, 'count_total' => true ) ); 100 $this->assertEqualsCanonicalizing( $visible_ids, wp_list_pluck( $activity['activities'], 'id' ) ); 101 $this->assertSame( count( $visible_ids ), (int) $activity['total'] ); 102 103 $response = rest_do_request( $request ); 104 $this->assertSame( 200, $response->get_status() ); 105 $this->assertEqualsCanonicalizing( $visible_ids, wp_list_pluck( $response->get_data(), 'id' ) ); 106 107 foreach ( $ids as $activity_id ) { 108 $can_read = ! in_array( $activity_id, $protected_ids, true ); 109 $this->assertSame( $can_read, bp_activity_user_can_read( new BP_Activity_Activity( $activity_id ), $viewer ) ); 110 $this->assertSame( $can_read, 200 === rest_do_request( new WP_REST_Request( 'GET', $route . '/' . $activity_id ) )->get_status() ); 111 } 112 } 113 114 require_once ABSPATH . WPINC . '/class-phpass.php'; 115 $hasher = new PasswordHash( 8, true ); 116 $_COOKIE[ $cookie ] = $hasher->HashPassword( $password ); 117 wp_set_current_user( 0 ); 118 119 $unlocked = bp_activity_get( array( 'in' => $ids, 'count_total' => true ) ); 120 $this->assertEqualsCanonicalizing( $ids, wp_list_pluck( $unlocked['activities'], 'id' ) ); 121 $this->assertSame( count( $ids ), (int) $unlocked['total'] ); 122 $this->assertEqualsCanonicalizing( $ids, wp_list_pluck( rest_do_request( $request )->get_data(), 'id' ) ); 123 124 foreach ( $ids as $activity_id ) { 125 $this->assertTrue( bp_activity_user_can_read( new BP_Activity_Activity( $activity_id ), 0 ) ); 126 } 127 128 unset( $_COOKIE[ $cookie ] ); 129 $locked = bp_activity_get( array( 'in' => $ids, 'count_total' => true ) ); 130 $this->assertEqualsCanonicalizing( $visible_ids, wp_list_pluck( $locked['activities'], 'id' ) ); 131 } finally { 132 wp_set_current_user( $old_user ); 133 if ( null === $old_cookie ) { 134 unset( $_COOKIE[ $cookie ] ); 135 } else { 136 $_COOKIE[ $cookie ] = $old_cookie; 137 } 138 } 139 } 140 141 /** 142 * Filter-added forum trees remain protected, including non-countable children. 143 */ 144 public function test_activity_password_descendants_preserve_subforum_filter() { 145 $parent = $this->factory->forum->create( array( 'post_password' => 'forum-secret' ) ); 146 $virtual = $this->factory->forum->create(); 147 $middle = $this->factory->forum->create( array( 'post_parent' => $virtual ) ); 148 $tree = $this->create_password_activity_tree( $middle ); 149 $public = $this->create_password_activity_tree(); 150 $visited = array(); 151 $inputs = array(); 152 wp_update_post( array( 'ID' => $middle, 'post_status' => 'draft' ) ); 153 154 $filter = function( $children, $forum_id ) use ( $parent, $virtual, $tree, &$visited, &$inputs ) { 155 $visited[] = (int) $forum_id; 156 $inputs[ $forum_id ] = wp_parse_id_list( $children ); 157 if ( $parent === (int) $forum_id ) { 158 $children[] = $virtual; 159 } 160 if ( $tree['forum'] === (int) $forum_id ) { 161 $children = array_merge( $children, array( $parent, $virtual, $tree['forum'] ) ); 162 } 163 return $children; 164 }; 165 add_filter( 'bbp_forum_query_subforum_ids', $filter, 10, 2 ); 166 167 try { 168 wp_set_current_user( 0 ); 169 $ids = array_merge( $tree['activities'], $public['activities'] ); 170 $activity = bp_activity_get( array( 'in' => $ids, 'count_total' => true ) ); 171 $this->assertEqualsCanonicalizing( $public['activities'], wp_list_pluck( $activity['activities'], 'id' ) ); 172 $this->assertSame( count( $public['activities'] ), (int) $activity['total'] ); 173 $this->assertContains( $parent, $visited ); 174 $this->assertContains( $virtual, $visited ); 175 $this->assertContains( $middle, $visited ); 176 $this->assertContains( $tree['forum'], $visited ); 177 $this->assertCount( count( array_unique( $visited ) ), $visited ); 178 $this->assertSame( array(), $inputs[ $virtual ] ); 179 $this->assertSame( array( $tree['forum'] ), $inputs[ $middle ] ); 180 } finally { 181 remove_filter( 'bbp_forum_query_subforum_ids', $filter, 10 ); 182 } 183 } 184 185 /** 186 * Invalid filter IDs must not expand into unrelated forum trees. 187 */ 188 public function test_activity_password_subforum_filter_ignores_invalid_ids() { 189 $parent = $this->factory->forum->create( array( 'post_password' => 'forum-secret' ) ); 190 $public = $this->create_password_activity_tree(); 191 $filter = function( $children, $forum_id ) use ( $parent, $public ) { 192 return ( $parent === (int) $forum_id ) 193 ? array( 0, '', null, 'invalid', -$public['forum'] ) 194 : $children; 195 }; 196 add_filter( 'bbp_forum_query_subforum_ids', $filter, 10, 2 ); 197 198 try { 199 wp_set_current_user( 0 ); 200 $activity = bp_activity_get( array( 'in' => $public['activities'], 'count_total' => true ) ); 201 $this->assertEqualsCanonicalizing( $public['activities'], wp_list_pluck( $activity['activities'], 'id' ) ); 202 $this->assertSame( count( $public['activities'] ), (int) $activity['total'] ); 203 } finally { 204 remove_filter( 'bbp_forum_query_subforum_ids', $filter, 10 ); 205 } 206 } 207 208 /** 209 * A failed password lookup is retried rather than cached as an empty result. 210 */ 211 public function test_activity_password_lookup_retries_database_errors() { 212 global $wpdb; 213 214 $tree = $this->create_password_activity_tree(); 215 $public = $this->create_password_activity_tree(); 216 $wpdb->update( $wpdb->posts, array( 'post_password' => 'forum-secret' ), array( 'ID' => $tree['forum'] ) ); 217 clean_post_cache( $tree['forum'] ); 218 $queries = 0; 219 $counter = function( $query ) use ( $wpdb, &$queries ) { 220 if ( false !== strpos( $query, 'CHAR_LENGTH( post_password ) > 0' ) ) { 221 $queries++; 222 if ( 1 === $queries ) { 223 return "SELECT bbpress_missing_password_column FROM {$wpdb->posts}"; 224 } 225 } 226 return $query; 227 }; 228 $old_errors = $wpdb->suppress_errors(); 229 add_filter( 'query', $counter ); 230 231 try { 232 wp_set_current_user( 0 ); 233 $args = array( 'in' => array_merge( $tree['activities'], $public['activities'] ), 'count_total' => true ); 234 bp_activity_get( $args ); 235 $this->assertSame( 1, $queries ); 236 $activity = bp_activity_get( $args ); 237 $this->assertSame( 2, $queries ); 238 $this->assertEqualsCanonicalizing( $public['activities'], wp_list_pluck( $activity['activities'], 'id' ) ); 239 $this->assertSame( count( $public['activities'] ), (int) $activity['total'] ); 240 } finally { 241 remove_filter( 'query', $counter ); 242 $wpdb->suppress_errors( $old_errors ); 243 } 244 } 245 246 /** 247 * Cached password IDs, including an empty lookup, refresh after post changes. 248 */ 249 public function test_activity_password_lookup_cache_refreshes_after_post_changes() { 250 global $wpdb; 251 252 $tree = $this->create_password_activity_tree(); 253 $public = $this->create_password_activity_tree(); 254 $ids = array_merge( $tree['activities'], $public['activities'] ); 255 $queries = 0; 256 $counter = function( $query ) use ( &$queries ) { 257 if ( false !== strpos( $query, 'CHAR_LENGTH( post_password ) > 0' ) ) { 258 $queries++; 259 } 260 return $query; 261 }; 262 add_filter( 'query', $counter ); 263 264 try { 265 wp_set_current_user( 0 ); 266 foreach ( array( '', ' ', '' ) as $index => $password ) { 267 if ( $index ) { 268 $this->assertSame( 1, $wpdb->update( $wpdb->posts, array( 'post_password' => $password ), array( 'ID' => $tree['forum'] ) ) ); 269 clean_post_cache( $tree['forum'] ); 270 } 271 272 $expected = empty( $password ) ? $ids : $public['activities']; 273 for ( $query = 0; $query < 2; $query++ ) { 274 $activity = bp_activity_get( array( 'in' => $ids, 'count_total' => true ) ); 275 $this->assertEqualsCanonicalizing( $expected, wp_list_pluck( $activity['activities'], 'id' ) ); 276 $this->assertSame( count( $expected ), (int) $activity['total'] ); 277 $this->assertSame( $index + 1, $queries ); 278 } 279 } 280 } finally { 281 remove_filter( 'query', $counter ); 282 } 283 } 284 285 /** 286 * Cached password IDs stay separate across sites and survive a round trip. 287 */ 288 public function test_activity_password_lookup_cache_is_separate_per_site() { 289 if ( ! is_multisite() ) { 290 $this->markTestSkipped( 'Multisite only.' ); 291 } 292 global $wpdb; 293 294 $blog_id = $this->factory->blog->create(); 295 $this->factory->forum->create( array( 'post_password' => 'forum-secret' ) ); 296 $original = $wpdb->posts; 297 $tables = array(); 298 $counter = function( $query ) use ( $wpdb, &$tables ) { 299 if ( false !== strpos( $query, 'CHAR_LENGTH( post_password ) > 0' ) ) { 300 $tables[] = $wpdb->posts; 301 } 302 return $query; 303 }; 304 $activity = bbpress()->extend->buddypress->activity; 305 $args = array( 'filter' => array( 'action' => 'bbp_topic_create' ) ); 306 add_filter( 'query', $counter ); 307 $switched = false; 308 309 try { 310 $conditions = $activity->activity_get_where_conditions( array(), $args ); 311 $this->assertArrayHasKey( 'bbpress_topic_visibility', $conditions ); 312 switch_to_blog( $blog_id ); 313 $switched = true; 314 $other = $wpdb->posts; 315 $this->assertSame( array(), $activity->activity_get_where_conditions( array(), $args ) ); 316 restore_current_blog(); 317 $switched = false; 318 $this->assertSame( $conditions, $activity->activity_get_where_conditions( array(), $args ) ); 319 $this->assertSame( array( $original, $other ), $tables ); 320 } finally { 321 if ( $switched ) { 322 restore_current_blog(); 323 } 324 remove_filter( 'query', $counter ); 325 } 326 } 327 328 /** 329 * Create real topic and reply activity before changing password visibility. 330 * 331 * @param int $parent Parent forum. 332 * @param bool $group_activity Whether to attach a public BuddyPress group. 333 * @return array Created posts and their activity IDs. 334 */ 335 private function create_password_activity_tree( $parent = 0, $group_activity = false ) { 336 $user_id = $this->factory->user->create(); 337 $forum_id = $this->factory->forum->create( array( 'post_parent' => $parent ) ); 338 $topic_id = $this->factory->topic->create( array( 'post_parent' => $forum_id, 'post_author' => $user_id ) ); 339 $reply_id = $this->factory->reply->create( array( 'post_parent' => $topic_id, 'post_author' => $user_id ) ); 340 $old_group = isset( buddypress()->groups->current_group ) ? buddypress()->groups->current_group : null; 341 342 try { 343 if ( $group_activity ) { 344 $group_id = $this->bp_factory->group->create( array( 'creator_id' => $user_id, 'status' => 'public' ) ); 345 bbp_add_forum_id_to_group( $group_id, $forum_id ); 346 bbp_add_group_id_to_forum( $forum_id, $group_id ); 347 buddypress()->groups->current_group = groups_get_group( $group_id ); 348 } 349 350 bbpress()->extend->buddypress->activity->topic_create( $topic_id, $forum_id, array(), $user_id ); 351 bbpress()->extend->buddypress->activity->reply_create( $reply_id, $topic_id, $forum_id, array(), $user_id ); 352 } finally { 353 if ( null === $old_group ) { 354 unset( buddypress()->groups->current_group ); 355 } else { 356 buddypress()->groups->current_group = $old_group; 357 } 358 } 359 360 $activity_ids = array( (int) get_post_meta( $topic_id, '_bbp_activity_id', true ), (int) get_post_meta( $reply_id, '_bbp_activity_id', true ) ); 361 $this->assertNotContains( 0, $activity_ids ); 362 363 foreach ( array( $topic_id, $reply_id ) as $index => $post_id ) { 364 $activity = new BP_Activity_Activity( $activity_ids[ $index ] ); 365 $this->assertSame( $group_activity ? buddypress()->groups->id : 'bbpress', $activity->component ); 366 $this->assertSame( $post_id, (int) ( $group_activity ? $activity->secondary_item_id : $activity->item_id ) ); 367 } 368 369 return array( 'forum' => $forum_id, 'topic' => $topic_id, 'reply' => $reply_id, 'activities' => $activity_ids ); 370 } 371 11 372 12 373 /** … … 861 1222 */ 862 1223 public function test_unrelated_activity_filters_skip_password_lookup() { 1224 $forum_id = $this->factory->forum->create( array( 'post_password' => 'forum-secret' ) ); 863 1225 $password_queries = 0; 1226 $password_checks = 0; 864 1227 $count_queries = function( $query ) use ( &$password_queries ) { 865 if ( true === $query->get( 'has_password' ) ) {1228 if ( false !== strpos( $query, 'CHAR_LENGTH( post_password ) > 0' ) ) { 866 1229 $password_queries++; 867 1230 } 1231 return $query; 868 1232 }; 869 870 add_action( 'pre_get_posts', $count_queries ); 1233 $count_checks = function( $required, $post ) use ( $forum_id, &$password_checks ) { 1234 if ( $forum_id === (int) $post->ID ) { 1235 $password_checks++; 1236 } 1237 return $required; 1238 }; 1239 1240 add_filter( 'query', $count_queries ); 1241 add_filter( 'post_password_required', $count_checks, 10, 2 ); 871 1242 872 1243 try { … … 874 1245 bp_activity_get( array( 'filter' => array( 'object' => 'activity' ) ) ); 875 1246 $this->assertSame( 0, $password_queries ); 1247 $this->assertSame( 0, $password_checks ); 876 1248 877 1249 bp_activity_get( array( 'filter' => array( 'action' => 'activity_update,bbp_topic_create' ) ) ); 878 1250 bp_activity_get( array( 'filter' => array( 'object' => array( 'activity', 'bbpress' ) ) ) ); 879 $this->assertSame( 2, $password_queries ); 1251 $this->assertSame( 1, $password_queries ); 1252 $this->assertSame( 2, $password_checks ); 880 1253 881 1254 bp_activity_get( array( 'filter' => array( 'action' => array( ' BBP_TOPIC_CREATE ' ) ) ) ); … … 885 1258 bp_activity_get( array( 'filter' => array( 'action' => 'activity_update,0e1' ) ) ); 886 1259 bp_activity_get( array( 'filter' => array( 'object' => array( 0 ) ) ) ); 887 $this->assertSame( 8, $password_queries ); 1260 $this->assertSame( 1, $password_queries ); 1261 $this->assertSame( 8, $password_checks ); 888 1262 889 1263 if ( bp_is_active( 'groups' ) ) { 890 1264 bp_activity_get( array( 'filter' => array( 'object' => buddypress()->groups->id ) ) ); 891 $this->assertSame( 9, $password_queries ); 1265 $this->assertSame( 1, $password_queries ); 1266 $this->assertSame( 9, $password_checks ); 892 1267 } 893 1268 } finally { 894 remove_action( 'pre_get_posts', $count_queries ); 1269 remove_filter( 'query', $count_queries ); 1270 remove_filter( 'post_password_required', $count_checks, 10 ); 895 1271 } 896 1272 }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)