From b3006d1e1782a4bbe728b83d087efb017e79ef35 Mon Sep 17 00:00:00 2001 From: Martin Paul Eve Date: Sun, 4 Oct 2026 14:12:07 +0100 Subject: [PATCH 1/8] fix(hc-custom): scope group events calendar to the current group Event Organiser fetches calendar events via admin-ajax, where BuddyPress skips URI parsing so bp_is_group() is false and bp-event-organiser's bp_group calendar filter never applies. Forward the calendar's bp_group setting on the AJAX request and resolve the group from it (or from page context) when building the calendar query. Fixes #131 --- .../hc-custom/includes/bp-event-organiser.php | 79 ++++++++++ .../includes/js/bpeo-group-calendar.js | 49 +++++++ tests/unit/BpeoGroupCalendarScopeTest.php | 138 ++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 plugins/hc-custom/includes/js/bpeo-group-calendar.js create mode 100644 tests/unit/BpeoGroupCalendarScopeTest.php diff --git a/plugins/hc-custom/includes/bp-event-organiser.php b/plugins/hc-custom/includes/bp-event-organiser.php index 6002adf8f..68eb026f0 100644 --- a/plugins/hc-custom/includes/bp-event-organiser.php +++ b/plugins/hc-custom/includes/bp-event-organiser.php @@ -549,3 +549,82 @@ function hc_custom_bpeo_activity_action_format_for_groups( $action, $activity ) + +/** Group calendar scoping *****************************************************/ + +/** + * Resolve the group a calendar request is being built for. + * + * @param array|null $request Request vars; defaults to $_GET. + * @return int Group ID, or 0 when the request is not for a group. + */ +function hc_custom_bpeo_get_calendar_group_id( $request = null ) { + // On the group page itself BuddyPress knows the group. + if ( function_exists( 'bp_is_group' ) && bp_is_group() ) { + return (int) bp_get_current_group_id(); + } + + // Inside the eventorganiser-fullcal admin-ajax request BuddyPress has no + // group context (it skips URI parsing for AJAX), so the calendar passes + // the group along explicitly. + if ( null === $request ) { + $request = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } + + if ( ! isset( $request['bp_group'] ) || ! is_scalar( $request['bp_group'] ) ) { + return 0; + } + + $bp_group = (string) $request['bp_group']; + if ( '' === $bp_group || ! ctype_digit( $bp_group ) ) { + return 0; + } + + return (int) $bp_group; +} + +/** + * Restrict the Event Organiser calendar query to the current group's events. + * + * @param array $query Query vars as set up by EO. + * @return array + */ +function hc_custom_bpeo_filter_calendar_query_for_group( $query ) { + $group_id = hc_custom_bpeo_get_calendar_group_id(); + + if ( $group_id ) { + // Consumed by bpeo's pre_get_posts handling of 'bp_group', which turns + // it into a bpeo_event_group tax query. + $query['bp_group'] = $group_id; + } + + return $query; +} +// After bp-event-organiser's own filter (priority 10), which only works when +// bp_is_group() is true and so is a no-op inside admin-ajax. +add_filter( 'eventorganiser_fullcalendar_query', 'hc_custom_bpeo_filter_calendar_query_for_group', 20 ); + +/** + * Load the script that sends the calendar's group along with its AJAX requests. + * + * Event Organiser enqueues 'eo_front' from wp_footer only when a calendar is on + * the page, so this runs just before footer scripts print and piggybacks on it. + * Hooked to wp_print_footer_scripts rather than wp_footer because the embedded + * group calendar (?embedded=true) strips wp_footer actions. + */ +function hc_custom_bpeo_enqueue_group_calendar_script() { + if ( ! wp_script_is( 'eo_front', 'enqueued' ) ) { + return; + } + + $js_path = 'includes/js/bpeo-group-calendar.js'; + + wp_enqueue_script( + 'hc-custom-bpeo-group-calendar', + plugins_url( $js_path, __DIR__ ), + array( 'eo_front' ), + filemtime( trailingslashit( plugin_dir_path( __DIR__ ) ) . $js_path ), + true + ); +} +add_action( 'wp_print_footer_scripts', 'hc_custom_bpeo_enqueue_group_calendar_script', 5 ); diff --git a/plugins/hc-custom/includes/js/bpeo-group-calendar.js b/plugins/hc-custom/includes/js/bpeo-group-calendar.js new file mode 100644 index 000000000..151f5eef5 --- /dev/null +++ b/plugins/hc-custom/includes/js/bpeo-group-calendar.js @@ -0,0 +1,49 @@ +/** + * Send the group a calendar belongs to along with its event requests. + * + * Event Organiser fetches calendar events via admin-ajax, where BuddyPress has + * no group context. bp-event-organiser already places 'bp_group' in the + * localized calendar settings (eventorganiser.calendars); this forwards it on + * the request so the server can scope the results to the group. + */ +( function ( window ) { + 'use strict'; + + /** + * Map a calendar element ID ("#eo_fullcalendar_N") to its index in + * eventorganiser.calendars. + */ + function calendarIndexFromId( id ) { + var match = /eo_fullcalendar_(\d+)/.exec( String( id || '' ) ); + return match ? parseInt( match[ 1 ], 10 ) - 1 : -1; + } + + function addGroupToRequest( request, start, end, timezone, options ) { + var calendars = window.eventorganiser && window.eventorganiser.calendars; + var index = calendarIndexFromId( options && options.id ); + var calendar = calendars && index >= 0 ? calendars[ index ] : null; + + if ( calendar && calendar.bp_group ) { + request.bp_group = calendar.bp_group; + } + + return request; + } + + var hooks = window.wp && window.wp.hooks; + + if ( ! hooks || typeof hooks.addFilter !== 'function' ) { + return; + } + + // Event Organiser ships a legacy hooks shim (event-manager.js) with the + // signature addFilter( name, callback, priority ), but defers to WordPress + // core's @wordpress/hooks when that is already on the page, which takes + // addFilter( name, namespace, callback, priority ). Core exposes hasFilter; + // the shim does not. + if ( typeof hooks.hasFilter === 'function' ) { + hooks.addFilter( 'eventorganiser.fullcalendar_request', 'hc-custom/bpeo-group-calendar', addGroupToRequest, 10 ); + } else { + hooks.addFilter( 'eventorganiser.fullcalendar_request', addGroupToRequest, 10 ); + } +} )( window ); diff --git a/tests/unit/BpeoGroupCalendarScopeTest.php b/tests/unit/BpeoGroupCalendarScopeTest.php new file mode 100644 index 000000000..48df82b7e --- /dev/null +++ b/tests/unit/BpeoGroupCalendarScopeTest.php @@ -0,0 +1,138 @@ +assertSame( 50, hc_custom_bpeo_get_calendar_group_id() ); + } + + public function test_group_resolved_from_request_outside_group_context() { + // admin-ajax: no BP group context, but the calendar passes bp_group. + $GLOBALS['_hc_mock']['is_group'] = false; + + $this->assertSame( 50, hc_custom_bpeo_get_calendar_group_id( array( 'bp_group' => '50' ) ) ); + } + + public function test_group_resolved_from_superglobal_when_no_request_given() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '72'; + + $this->assertSame( 72, hc_custom_bpeo_get_calendar_group_id() ); + } + + public function test_page_context_wins_over_request_parameter() { + $GLOBALS['_hc_mock']['is_group'] = true; + $GLOBALS['_hc_mock']['current_group_id'] = 50; + + $this->assertSame( 50, hc_custom_bpeo_get_calendar_group_id( array( 'bp_group' => '99' ) ) ); + } + + public function test_no_group_when_neither_context_nor_request_identify_one() { + $GLOBALS['_hc_mock']['is_group'] = false; + + $this->assertSame( 0, hc_custom_bpeo_get_calendar_group_id( array() ) ); + } + + /** + * @dataProvider invalidGroupParameters + */ + public function test_invalid_request_parameter_yields_no_group( $value ) { + $GLOBALS['_hc_mock']['is_group'] = false; + + $this->assertSame( 0, hc_custom_bpeo_get_calendar_group_id( array( 'bp_group' => $value ) ) ); + } + + public function invalidGroupParameters() { + return array( + 'non-numeric' => array( 'mpes-grp' ), + 'negative' => array( '-5' ), + 'zero' => array( '0' ), + 'empty string' => array( '' ), + 'array' => array( array( '50' ) ), + 'sql-ish' => array( '50 OR 1=1' ), + ); + } + + // ----------------------------------------------------------------------- + // The Event Organiser calendar query. + // ----------------------------------------------------------------------- + + public function test_calendar_query_scoped_to_group_from_request() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'showpastevents' => true ) ); + + $this->assertSame( 50, $query['bp_group'] ); + } + + public function test_calendar_query_scoped_to_group_from_page_context() { + $GLOBALS['_hc_mock']['is_group'] = true; + $GLOBALS['_hc_mock']['current_group_id'] = 50; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( 50, $query['bp_group'] ); + } + + public function test_calendar_query_preserves_existing_arguments() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $in = array( + 'event_start_before' => '2026-11-01', + 'event_end_after' => '2026-09-28', + 'post_status' => array( 'publish', 'private' ), + ); + $out = hc_custom_bpeo_filter_calendar_query_for_group( $in ); + + foreach ( $in as $key => $value ) { + $this->assertSame( $value, $out[ $key ] ); + } + } + + public function test_calendar_query_untouched_outside_any_group_context() { + $GLOBALS['_hc_mock']['is_group'] = false; + + $in = array( 'event_start_before' => '2026-11-01' ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_group( $in ) ); + } + + public function test_calendar_query_untouched_for_invalid_request_group() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = 'not-a-group'; + + $in = array( 'event_start_before' => '2026-11-01' ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_group( $in ) ); + } +} From 7a402a9ceb2dfd0bc498875799b3f340bccd00c9 Mon Sep 17 00:00:00 2001 From: Martin Paul Eve Date: Sun, 4 Oct 2026 14:12:46 +0100 Subject: [PATCH 2/8] fix(hc-custom): link group calendar events to the group's event page bp-event-organiser rewrites calendar links to /events// only when bp_is_group() is true, which never holds inside the admin-ajax request that builds the calendar payload. Resolve the group the calendar is for and rewrite links for events connected to it. Fixes #132 --- .../hc-custom/includes/bp-event-organiser.php | 33 +++++++ tests/unit/BpeoGroupCalendarLinksTest.php | 94 +++++++++++++++++++ tests/unit/group-permissions-loader.php | 7 ++ 3 files changed, 134 insertions(+) create mode 100644 tests/unit/BpeoGroupCalendarLinksTest.php diff --git a/plugins/hc-custom/includes/bp-event-organiser.php b/plugins/hc-custom/includes/bp-event-organiser.php index 68eb026f0..ceaee8ed2 100644 --- a/plugins/hc-custom/includes/bp-event-organiser.php +++ b/plugins/hc-custom/includes/bp-event-organiser.php @@ -628,3 +628,36 @@ function hc_custom_bpeo_enqueue_group_calendar_script() { ); } add_action( 'wp_print_footer_scripts', 'hc_custom_bpeo_enqueue_group_calendar_script', 5 ); + +/** + * Point calendar event links at the group's rendering of the event. + * + * @param string $link Current event permalink. + * @param int $event_id Event post ID. + * @param int $occurrence_id Occurrence ID. + * @return string + */ +function hc_custom_bpeo_filter_calendar_event_link_for_group( $link, $event_id = 0, $occurrence_id = 0 ) { + $group_id = hc_custom_bpeo_get_calendar_group_id(); + $event_id = (int) $event_id; + + if ( ! $group_id || ! $event_id ) { + return $link; + } + + // Only events actually connected to this group have a rendering under it. + $event_groups = array_map( 'intval', (array) bpeo_get_event_groups( $event_id ) ); + if ( ! in_array( $group_id, $event_groups, true ) ) { + return $link; + } + + $event = get_post( $event_id ); + if ( ! $event || empty( $event->post_name ) ) { + return $link; + } + + return trailingslashit( bpeo_get_group_permalink( $group_id ) . $event->post_name ); +} +// After bp-event-organiser's own filter (priority 10), which only works when +// bp_is_group() is true and so is a no-op inside admin-ajax. +add_filter( 'eventorganiser_calendar_event_link', 'hc_custom_bpeo_filter_calendar_event_link_for_group', 20, 3 ); diff --git a/tests/unit/BpeoGroupCalendarLinksTest.php b/tests/unit/BpeoGroupCalendarLinksTest.php new file mode 100644 index 000000000..0cc862a5e --- /dev/null +++ b/tests/unit/BpeoGroupCalendarLinksTest.php @@ -0,0 +1,94 @@ +/events//) rather than the canonical event page. This has to + * work inside the admin-ajax request that builds the calendar payload, where + * BuddyPress has no group context and the group arrives as a request param. + */ + +use PHPUnit\Framework\TestCase; + +class BpeoGroupCalendarLinksTest extends TestCase { + + const CANONICAL = 'https://example.org/events/event/zoo-visit/'; + + protected function setUp(): void { + $GLOBALS['_hc_mock'] = array(); + $_GET = array(); + + $event = new stdClass(); + $event->ID = 7; + $event->post_type = 'event'; + $event->post_name = 'zoo-visit'; + + $GLOBALS['_hc_mock']['posts'] = array( 7 => $event ); + $GLOBALS['_hc_mock']['event_groups'] = array( 7 => array( 50 ) ); + $GLOBALS['_hc_mock']['events_group_permalinks'] = array( + 50 => 'https://example.org/groups/mpes-grp/events/', + 60 => 'https://example.org/groups/other-grp/events/', + ); + } + + protected function tearDown(): void { + $_GET = array(); + } + + public function test_link_rewritten_to_group_event_url_in_ajax_context() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $link = hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL, 7, 1 ); + + $this->assertSame( 'https://example.org/groups/mpes-grp/events/zoo-visit/', $link ); + } + + public function test_link_rewritten_to_group_event_url_in_page_context() { + $GLOBALS['_hc_mock']['is_group'] = true; + $GLOBALS['_hc_mock']['current_group_id'] = 50; + + $link = hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL, 7 ); + + $this->assertSame( 'https://example.org/groups/mpes-grp/events/zoo-visit/', $link ); + } + + public function test_link_uses_the_requested_group_not_another_connected_group() { + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['event_groups'] = array( 7 => array( 50, 60 ) ); + $_GET['bp_group'] = '60'; + + $link = hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL, 7, 1 ); + + $this->assertSame( 'https://example.org/groups/other-grp/events/zoo-visit/', $link ); + } + + public function test_link_unchanged_outside_group_context() { + $GLOBALS['_hc_mock']['is_group'] = false; + + $this->assertSame( self::CANONICAL, hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL, 7, 1 ) ); + } + + public function test_link_unchanged_when_event_not_connected_to_requested_group() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '60'; + + $this->assertSame( self::CANONICAL, hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL, 7, 1 ) ); + } + + public function test_link_unchanged_when_event_cannot_be_loaded() { + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['posts'] = array(); + $_GET['bp_group'] = '50'; + + $this->assertSame( self::CANONICAL, hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL, 7, 1 ) ); + } + + public function test_link_unchanged_when_no_event_id_given() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $this->assertSame( self::CANONICAL, hc_custom_bpeo_filter_calendar_event_link_for_group( self::CANONICAL ) ); + } +} diff --git a/tests/unit/group-permissions-loader.php b/tests/unit/group-permissions-loader.php index 601d95c60..daa9b2230 100644 --- a/tests/unit/group-permissions-loader.php +++ b/tests/unit/group-permissions-loader.php @@ -342,6 +342,13 @@ function bpeo_get_group_minimum_member_role_for_connection( $group_id ) { if ( ! function_exists( 'bpeo_get_group_permalink' ) ) { function bpeo_get_group_permalink( $group = null ) { + // Per-group permalinks may be configured via 'events_group_permalinks' + // (keyed by group ID); otherwise fall back to the single default. + $group_id = is_object( $group ) ? ( $group->id ?? 0 ) : (int) $group; + $map = _hc_mock( 'events_group_permalinks', array() ); + if ( $group_id && isset( $map[ $group_id ] ) ) { + return $map[ $group_id ]; + } return _hc_mock( 'events_group_permalink', 'https://example.org/groups/test-group/events/' ); } } From 1e9cda95d2a3226b298e769dca6363fb5a0682c5 Mon Sep 17 00:00:00 2001 From: Martin Paul Eve Date: Sun, 4 Oct 2026 15:41:04 +0100 Subject: [PATCH 3/8] fix(hc-custom): check group access before scoping the calendar The calendar AJAX endpoint has none of the group page's access control, so a caller could name any group ID and receive that group's events, including the private events of private and hidden groups. Only honour the bp_group scope for public groups, group members and community moderators; otherwise return an empty scope (no events) rather than the unscoped calendar. Where access is granted, drop EO's capability-based 'readable' restriction so members still see their group's private events. Refs #131 --- .../hc-custom/includes/bp-event-organiser.php | 54 ++++++- tests/unit/BpeoGroupCalendarScopeTest.php | 134 ++++++++++++++++++ 2 files changed, 184 insertions(+), 4 deletions(-) diff --git a/plugins/hc-custom/includes/bp-event-organiser.php b/plugins/hc-custom/includes/bp-event-organiser.php index ceaee8ed2..a0405ab0c 100644 --- a/plugins/hc-custom/includes/bp-event-organiser.php +++ b/plugins/hc-custom/includes/bp-event-organiser.php @@ -592,14 +592,60 @@ function hc_custom_bpeo_get_calendar_group_id( $request = null ) { function hc_custom_bpeo_filter_calendar_query_for_group( $query ) { $group_id = hc_custom_bpeo_get_calendar_group_id(); - if ( $group_id ) { - // Consumed by bpeo's pre_get_posts handling of 'bp_group', which turns - // it into a bpeo_event_group tax query. - $query['bp_group'] = $group_id; + if ( ! $group_id ) { + return $query; } + // The admin-ajax endpoint has none of the group page's access control, so + // only honour the scope for groups the caller may see. A denied request + // gets an empty scope, which bpeo's pre_get_posts handler turns into + // post__in => array( 0 ), i.e. no events, rather than the unscoped calendar. + if ( ! hc_custom_bpeo_user_can_view_group_calendar( get_current_user_id(), $group_id ) ) { + $query['bp_group'] = array(); + return $query; + } + + // Consumed by bpeo's pre_get_posts handling of 'bp_group', which turns it + // into a bpeo_event_group tax query (and admits private events, which is + // how events in non-public groups are stored). + $query['bp_group'] = $group_id; + + // Access has been checked against the group itself, so do not let EO's + // capability-based 'readable' restriction hide the group's private events + // from its members on top of that. + $query['perm'] = ''; + return $query; } + +/** + * Whether a user may view a group's calendar. + * + * Mirrors the visibility rule bp-event-organiser applies when listing an + * event's connected groups: public groups are open to all; private and hidden + * groups to their members and to community moderators. + * + * @param int $user_id User ID (0 for anonymous). + * @param int $group_id Group ID. + * @return bool + */ +function hc_custom_bpeo_user_can_view_group_calendar( $user_id, $group_id ) { + $group = groups_get_group( array( 'group_id' => (int) $group_id ) ); + + if ( empty( $group->id ) ) { + return false; + } + + if ( 'public' === $group->status ) { + return true; + } + + if ( current_user_can( 'bp_moderate' ) ) { + return true; + } + + return (bool) groups_is_user_member( (int) $user_id, (int) $group_id ); +} // After bp-event-organiser's own filter (priority 10), which only works when // bp_is_group() is true and so is a no-op inside admin-ajax. add_filter( 'eventorganiser_fullcalendar_query', 'hc_custom_bpeo_filter_calendar_query_for_group', 20 ); diff --git a/tests/unit/BpeoGroupCalendarScopeTest.php b/tests/unit/BpeoGroupCalendarScopeTest.php index 48df82b7e..e11ffaaa9 100644 --- a/tests/unit/BpeoGroupCalendarScopeTest.php +++ b/tests/unit/BpeoGroupCalendarScopeTest.php @@ -16,9 +16,20 @@ class BpeoGroupCalendarScopeTest extends TestCase { protected function setUp(): void { $GLOBALS['_hc_mock'] = array(); + $GLOBALS['hc_test'] = array(); $_GET = array(); } + /** + * Register a group with the given status in the groups_get_group() stub. + */ + private function setUpGroup( $group_id, $status = 'public' ) { + $GLOBALS['hc_test']['groups'][ $group_id ] = (object) array( + 'id' => $group_id, + 'status' => $status, + ); + } + protected function tearDown(): void { $_GET = array(); } @@ -86,6 +97,7 @@ public function invalidGroupParameters() { // ----------------------------------------------------------------------- public function test_calendar_query_scoped_to_group_from_request() { + $this->setUpGroup( 50 ); $GLOBALS['_hc_mock']['is_group'] = false; $_GET['bp_group'] = '50'; @@ -95,6 +107,7 @@ public function test_calendar_query_scoped_to_group_from_request() { } public function test_calendar_query_scoped_to_group_from_page_context() { + $this->setUpGroup( 50 ); $GLOBALS['_hc_mock']['is_group'] = true; $GLOBALS['_hc_mock']['current_group_id'] = 50; @@ -104,6 +117,7 @@ public function test_calendar_query_scoped_to_group_from_page_context() { } public function test_calendar_query_preserves_existing_arguments() { + $this->setUpGroup( 50 ); $GLOBALS['_hc_mock']['is_group'] = false; $_GET['bp_group'] = '50'; @@ -135,4 +149,124 @@ public function test_calendar_query_untouched_for_invalid_request_group() { $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_group( $in ) ); } + + // ----------------------------------------------------------------------- + // Group access: the AJAX endpoint has none of the group page's gating, so + // the scope must only be honoured for groups the caller may see. Denied + // requests yield an empty scope (no events), never the unscoped calendar. + // ----------------------------------------------------------------------- + + public function test_public_group_scoped_for_anonymous_caller() { + $this->setUpGroup( 50, 'public' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( 50, $query['bp_group'] ); + } + + public function test_private_group_denied_for_anonymous_caller() { + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( array(), $query['bp_group'] ); + } + + public function test_private_group_denied_for_logged_in_non_member() { + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['group_members'] = array( '7:60' ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( array(), $query['bp_group'] ); + } + + public function test_private_group_scoped_for_member() { + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['group_members'] = array( '7:50' ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( 50, $query['bp_group'] ); + } + + public function test_hidden_group_denied_for_non_member_and_scoped_for_member() { + $this->setUpGroup( 50, 'hidden' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $_GET['bp_group'] = '50'; + + $GLOBALS['_hc_mock']['group_members'] = array(); + $this->assertSame( array(), hc_custom_bpeo_filter_calendar_query_for_group( array() )['bp_group'] ); + + $GLOBALS['_hc_mock']['group_members'] = array( '7:50' ); + $this->assertSame( 50, hc_custom_bpeo_filter_calendar_query_for_group( array() )['bp_group'] ); + } + + public function test_private_group_scoped_for_moderator_who_is_not_a_member() { + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['user_can'] = array( 'bp_moderate' => true ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( 50, $query['bp_group'] ); + } + + public function test_nonexistent_group_yields_empty_scope() { + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '999'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( array(), $query['bp_group'] ); + } + + public function test_access_check_also_applies_in_page_context() { + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = true; + $GLOBALS['_hc_mock']['current_group_id'] = 50; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array() ); + + $this->assertSame( array(), $query['bp_group'] ); + } + + public function test_granted_scope_does_not_filter_private_events_by_capability() { + // Members of a private group must see its (private) events; access has + // already been verified against the group, so EO's capability-based + // 'readable' restriction must not apply on top. + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['group_members'] = array( '7:50' ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( '', $query['perm'] ); + } + + public function test_denied_scope_leaves_capability_restriction_in_place() { + $this->setUpGroup( 50, 'private' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( 'readable', $query['perm'] ); + } } From afd7dc9e1e2bdc07bac201d643d3e07fde706003 Mon Sep 17 00:00:00 2001 From: Martin Paul Eve Date: Sun, 4 Oct 2026 15:41:27 +0100 Subject: [PATCH 4/8] fix(hc-custom): stop granting read_private_events to everyone The event capability mapping treated read_private_events as a cap with no event context and fell through to the branch that returns 'exist', so every visitor, including anonymous ones, passed WP_Query's readable check for private events. Events in private and hidden groups are stored as private posts and were therefore listed for anyone. Leave the primitive cap on WordPress's role-based mapping; group members see their group's private events through the group-scoped calendar query, which checks group membership itself. Fixes #134 --- .../hc-custom/includes/bp-event-organiser.php | 5 +++- tests/unit/BpeoGroupEventCapsTest.php | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/plugins/hc-custom/includes/bp-event-organiser.php b/plugins/hc-custom/includes/bp-event-organiser.php index a0405ab0c..0e8fc5e91 100644 --- a/plugins/hc-custom/includes/bp-event-organiser.php +++ b/plugins/hc-custom/includes/bp-event-organiser.php @@ -177,7 +177,10 @@ function hc_custom_bpeo_group_event_meta_cap( $caps, $cap, $user_id, $args ) { } switch ( $cap ) { - case 'read_private_events': + // 'read_private_events' is deliberately not handled here: it is a + // primitive cap with no event context, so group membership cannot be + // evaluated for it. Mapping it to 'exist' (as this once did) handed + // every visitor the ability to list all private events. case 'read_event': // we've already parsed this logic in bpeo_map_basic_meta_caps(). if ( 'exist' === $caps[0] ) { diff --git a/tests/unit/BpeoGroupEventCapsTest.php b/tests/unit/BpeoGroupEventCapsTest.php index 66ee8d0e9..f7f8ccf36 100644 --- a/tests/unit/BpeoGroupEventCapsTest.php +++ b/tests/unit/BpeoGroupEventCapsTest.php @@ -178,4 +178,28 @@ public function test_non_event_cap_is_unchanged() { $this->assertSame( array( 'edit_posts' ), $caps ); } + + public function test_read_private_events_not_granted_without_event_context() { + // The primitive cap carries no event, so group membership cannot be + // evaluated; it must keep WordPress's role-based mapping rather than + // being handed to everyone (which let anyone list private events). + $caps = hc_custom_bpeo_group_event_meta_cap( array( 'read_private_events' ), 'read_private_events', 20, array() ); + + $this->assertSame( array( 'read_private_events' ), $caps ); + } + + public function test_read_private_events_not_granted_to_anonymous_user() { + $caps = hc_custom_bpeo_group_event_meta_cap( array( 'read_private_events' ), 'read_private_events', 0, array() ); + + $this->assertSame( array( 'read_private_events' ), $caps ); + } + + public function test_private_event_not_readable_by_non_member() { + $this->setUpGroupEvent( 408, 45, 'private' ); + $GLOBALS['_hc_mock']['user_groups'] = array( 21 => array( 46 ) ); + + $caps = hc_custom_bpeo_group_event_meta_cap( array( 'read_private_events' ), 'read_event', 21, array( 408 ) ); + + $this->assertSame( array( 'read_private_events' ), $caps ); + } } From 60febb5c4df0823f15d52d2cd2f5ea7c378af335 Mon Sep 17 00:00:00 2001 From: Martin Paul Eve Date: Sun, 4 Oct 2026 15:51:53 +0100 Subject: [PATCH 5/8] feat(hc-custom): show members their private groups' events sitewide With read_private_events no longer granted to everyone, the unscoped calendar hid private-group events from the groups' own members. For a logged-in user, look up the private events connected to their groups and admit exactly those (plus their own private events) alongside the public ones via a posts_where restriction. Anonymous visitors and non-members continue to see public events only; group-scoped and member calendars are left to their own visibility handling. Refs #134 --- .../hc-custom/includes/bp-event-organiser.php | 105 +++++++++++++ .../BpeoSitewideCalendarPrivateEventsTest.php | 141 ++++++++++++++++++ tests/unit/group-permissions-loader.php | 22 +++ 3 files changed, 268 insertions(+) create mode 100644 tests/unit/BpeoSitewideCalendarPrivateEventsTest.php diff --git a/plugins/hc-custom/includes/bp-event-organiser.php b/plugins/hc-custom/includes/bp-event-organiser.php index 0e8fc5e91..70f47074d 100644 --- a/plugins/hc-custom/includes/bp-event-organiser.php +++ b/plugins/hc-custom/includes/bp-event-organiser.php @@ -710,3 +710,108 @@ function hc_custom_bpeo_filter_calendar_event_link_for_group( $link, $event_id = // After bp-event-organiser's own filter (priority 10), which only works when // bp_is_group() is true and so is a no-op inside admin-ajax. add_filter( 'eventorganiser_calendar_event_link', 'hc_custom_bpeo_filter_calendar_event_link_for_group', 20, 3 ); + +/** Sitewide calendar: members' private group events ***************************/ + +/** + * IDs of private events connected to any of a user's groups. + * + * @param int $user_id User ID. + * @return int[] + */ +function hc_custom_bpeo_get_member_private_event_ids( $user_id ) { + $user_id = (int) $user_id; + if ( ! $user_id ) { + return array(); + } + + $user_groups = groups_get_user_groups( $user_id ); + $group_ids = ! empty( $user_groups['groups'] ) ? array_map( 'intval', (array) $user_groups['groups'] ) : array(); + if ( empty( $group_ids ) ) { + return array(); + } + + $group_terms = array(); + foreach ( $group_ids as $group_id ) { + $group_terms[] = 'group_' . $group_id; + } + + // Same shape of query bp-event-organiser uses for a group's events. + $q = new WP_Query( array( + 'post_type' => 'event', + 'post_status' => 'private', + 'fields' => 'ids', + 'posts_per_page' => -1, + 'showpastevents' => true, + 'tax_query' => array( + array( + 'taxonomy' => 'bpeo_event_group', + 'field' => 'name', + 'terms' => $group_terms, + 'operator' => 'IN', + ), + ), + ) ); + + return array_values( array_filter( array_map( 'intval', (array) $q->posts ) ) ); +} + +/** + * Let a logged-in member see their private groups' events on the unscoped + * (sitewide) calendar. + * + * @param array $query Query vars as set up by EO. + * @return array + */ +function hc_custom_bpeo_filter_calendar_query_for_member_groups( $query ) { + // Group-scoped (including denied) and member calendars manage their own + // visibility. + if ( isset( $query['bp_group'] ) || isset( $query['bp_displayed_user_id'] ) ) { + return $query; + } + + $event_ids = hc_custom_bpeo_get_member_private_event_ids( get_current_user_id() ); + if ( empty( $event_ids ) ) { + return $query; + } + + // EO's 'readable' perm would limit private posts to the caller's own, + // which, now that read_private_events is no longer handed to everyone, + // would hide their groups' events. Admit private posts and let the + // posts_where restriction confine them to the visible IDs. + $query['hc_bpeo_private_event_ids'] = $event_ids; + $query['perm'] = ''; + $query['post_status'] = array_values( array_unique( array_merge( (array) ( $query['post_status'] ?? array( 'publish' ) ), array( 'private' ) ) ) ); + + return $query; +} +add_filter( 'eventorganiser_fullcalendar_query', 'hc_custom_bpeo_filter_calendar_query_for_member_groups', 30 ); + +/** + * Keep private events other than the caller's visible ones out of the query. + * + * @param string $where SQL WHERE clause. + * @param WP_Query $wp_query The query. + * @return string + */ +function hc_custom_bpeo_restrict_private_events_where( $where, $wp_query ) { + $event_ids = $wp_query->get( 'hc_bpeo_private_event_ids' ); + if ( empty( $event_ids ) ) { + return $where; + } + + $event_ids = array_values( array_filter( array_map( 'intval', (array) $event_ids ) ) ); + if ( empty( $event_ids ) ) { + return $where; + } + + global $wpdb; + $posts = $wpdb->posts; + $user_id = (int) get_current_user_id(); + $id_list = implode( ',', $event_ids ); + + $where .= " AND ( {$posts}.post_status <> 'private' OR {$posts}.post_author = {$user_id} OR {$posts}.ID IN ({$id_list}) )"; + + return $where; +} +add_filter( 'posts_where', 'hc_custom_bpeo_restrict_private_events_where', 20, 2 ); diff --git a/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php b/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php new file mode 100644 index 000000000..c2497380b --- /dev/null +++ b/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php @@ -0,0 +1,141 @@ + 'wp_posts' ); + } + + /** + * Log in user 3 as a member of groups 50 and 60, whose private events are + * 7 and 9. + */ + private function setUpMemberWithPrivateEvents() { + $GLOBALS['_hc_mock']['current_user_id'] = 3; + $GLOBALS['_hc_mock']['user_groups'] = array( 3 => array( 50, 60 ) ); + $GLOBALS['_hc_mock']['wp_query_callback'] = function ( $args ) { + return array( 7, 9 ); + }; + } + + // ----------------------------------------------------------------------- + // Which private events a member may see. + // ----------------------------------------------------------------------- + + public function test_member_private_event_ids_come_from_their_groups() { + $this->setUpMemberWithPrivateEvents(); + + $this->assertSame( array( 7, 9 ), hc_custom_bpeo_get_member_private_event_ids( 3 ) ); + } + + public function test_user_in_no_groups_has_no_private_event_ids() { + $GLOBALS['_hc_mock']['wp_query_callback'] = function ( $args ) { + return array( 7, 9 ); + }; + + $this->assertSame( array(), hc_custom_bpeo_get_member_private_event_ids( 4 ) ); + } + + public function test_anonymous_user_has_no_private_event_ids() { + $this->assertSame( array(), hc_custom_bpeo_get_member_private_event_ids( 0 ) ); + } + + // ----------------------------------------------------------------------- + // The calendar query. + // ----------------------------------------------------------------------- + + public function test_unscoped_query_unchanged_for_anonymous_caller() { + $in = array( 'perm' => 'readable', 'post_status' => array( 'publish', 'private' ) ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ) ); + } + + public function test_unscoped_query_unchanged_for_member_without_private_group_events() { + $GLOBALS['_hc_mock']['current_user_id'] = 3; + $GLOBALS['_hc_mock']['user_groups'] = array( 3 => array( 50 ) ); + + $in = array( 'perm' => 'readable', 'post_status' => array( 'publish', 'private' ) ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ) ); + } + + public function test_unscoped_query_admits_members_private_group_events() { + $this->setUpMemberWithPrivateEvents(); + + $out = hc_custom_bpeo_filter_calendar_query_for_member_groups( array( 'perm' => 'readable', 'post_status' => array( 'publish', 'private' ) ) ); + + $this->assertSame( array( 7, 9 ), $out['hc_bpeo_private_event_ids'] ); + $this->assertSame( '', $out['perm'] ); + $this->assertContains( 'private', (array) $out['post_status'] ); + $this->assertContains( 'publish', (array) $out['post_status'] ); + } + + public function test_group_scoped_query_is_left_alone() { + $this->setUpMemberWithPrivateEvents(); + + $in = array( 'bp_group' => 50, 'perm' => '' ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ) ); + } + + public function test_denied_group_scope_is_left_alone() { + $this->setUpMemberWithPrivateEvents(); + + $in = array( 'bp_group' => array(), 'perm' => 'readable' ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ) ); + } + + public function test_member_calendar_query_is_left_alone() { + $this->setUpMemberWithPrivateEvents(); + + $in = array( 'bp_displayed_user_id' => 3, 'perm' => 'readable' ); + + $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ) ); + } + + // ----------------------------------------------------------------------- + // The SQL restriction that keeps other private events out. + // ----------------------------------------------------------------------- + + public function test_where_clause_untouched_without_visible_private_events() { + $q = new WP_Query( array( 'post_type' => 'event' ) ); + + $this->assertSame( ' AND 1=1', hc_custom_bpeo_restrict_private_events_where( ' AND 1=1', $q ) ); + } + + public function test_where_clause_limits_private_posts_to_visible_ids_and_own_posts() { + $GLOBALS['_hc_mock']['current_user_id'] = 3; + $q = new WP_Query( array( 'post_type' => 'event', 'hc_bpeo_private_event_ids' => array( 7, 9 ) ) ); + + $where = hc_custom_bpeo_restrict_private_events_where( ' AND 1=1', $q ); + + $this->assertStringStartsWith( ' AND 1=1', $where ); + $this->assertStringContainsString( "wp_posts.post_status <> 'private'", $where ); + $this->assertStringContainsString( 'wp_posts.ID IN (7,9)', $where ); + $this->assertStringContainsString( 'wp_posts.post_author = 3', $where ); + } + + public function test_where_clause_only_ever_contains_integer_ids() { + $GLOBALS['_hc_mock']['current_user_id'] = 3; + $q = new WP_Query( array( 'hc_bpeo_private_event_ids' => array( '7', "9) OR (1=1", 'x', 0 ) ) ); + + $where = hc_custom_bpeo_restrict_private_events_where( '', $q ); + + $this->assertStringContainsString( 'wp_posts.ID IN (7,9)', $where ); + $this->assertStringNotContainsString( '1=1', $where ); + } +} diff --git a/tests/unit/group-permissions-loader.php b/tests/unit/group-permissions-loader.php index daa9b2230..32a16211b 100644 --- a/tests/unit/group-permissions-loader.php +++ b/tests/unit/group-permissions-loader.php @@ -365,6 +365,28 @@ function bpeo_get_events_new_slug() { } } +if ( ! class_exists( 'WP_Query' ) ) { + /** + * Minimal WP_Query stand-in: records its args and returns whatever the + * per-test callback in $GLOBALS['_hc_mock']['wp_query_callback'] yields + * for them (default: no posts). + */ + class WP_Query { + public $query_vars = array(); + public $posts = array(); + + public function __construct( $args = array() ) { + $this->query_vars = $args; + $cb = _hc_mock( 'wp_query_callback' ); + $this->posts = is_callable( $cb ) ? call_user_func( $cb, $args ) : array(); + } + + public function get( $var, $default = '' ) { + return isset( $this->query_vars[ $var ] ) ? $this->query_vars[ $var ] : $default; + } + } +} + // --------------------------------------------------------------------------- // Load the real code under test. // --------------------------------------------------------------------------- From 0e2803bff51edff72c3191220b664f00265d385a Mon Sep 17 00:00:00 2001 From: Martin Paul Eve Date: Sun, 4 Oct 2026 16:27:16 +0100 Subject: [PATCH 6/8] fix(hc-custom): keep full private-event access for roles that have it The sitewide membership restriction was also applied to callers whose role grants read_private_events, hiding private events outside their groups that EO's 'readable' query already authorises them to see. Leave the query unchanged for those callers. Refs #134 --- plugins/hc-custom/includes/bp-event-organiser.php | 7 +++++++ .../BpeoSitewideCalendarPrivateEventsTest.php | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/plugins/hc-custom/includes/bp-event-organiser.php b/plugins/hc-custom/includes/bp-event-organiser.php index 70f47074d..97016c0ea 100644 --- a/plugins/hc-custom/includes/bp-event-organiser.php +++ b/plugins/hc-custom/includes/bp-event-organiser.php @@ -770,6 +770,13 @@ function hc_custom_bpeo_filter_calendar_query_for_member_groups( $query ) { return $query; } + // Callers whose role grants read_private_events already see every private + // event through EO's 'readable' query; the membership-based restriction + // below would only take events away from them. + if ( current_user_can( 'read_private_events' ) ) { + return $query; + } + $event_ids = hc_custom_bpeo_get_member_private_event_ids( get_current_user_id() ); if ( empty( $event_ids ) ) { return $query; diff --git a/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php b/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php index c2497380b..33ae3fa4b 100644 --- a/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php +++ b/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php @@ -83,6 +83,21 @@ public function test_unscoped_query_admits_members_private_group_events() { $this->assertContains( 'publish', (array) $out['post_status'] ); } + public function test_unscoped_query_unchanged_for_caller_whose_role_reads_private_events() { + // An editor/admin is a member of groups with private events (7, 9) but + // is also entitled, by role, to private events outside those groups + // (e.g. 11). The membership-based restriction must not be applied on + // top of EO's 'readable' query, which already lets them see all of them. + $this->setUpMemberWithPrivateEvents(); + $GLOBALS['_hc_mock']['user_can'] = array( 'read_private_events' => true ); + + $in = array( 'perm' => 'readable', 'post_status' => array( 'publish', 'private' ) ); + $out = hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ); + + $this->assertSame( $in, $out ); + $this->assertArrayNotHasKey( 'hc_bpeo_private_event_ids', $out ); + } + public function test_group_scoped_query_is_left_alone() { $this->setUpMemberWithPrivateEvents(); From 93fd59e91607b68019b55f53b0ad1ca1dc6f20c4 Mon Sep 17 00:00:00 2001 From: Martin Paul Eve Date: Sun, 4 Oct 2026 17:10:40 +0100 Subject: [PATCH 7/8] fix(hc-custom): keep private-post checks for non-members on public groups Passing the group access check for a public group was enough to drop EO's 'readable' restriction, so an anonymous visitor could see a private event connected to both that public group and a private group. Only members of the requested group and community moderators bypass the capability check; other visitors keep the role/author rules. Refs #131 --- .../hc-custom/includes/bp-event-organiser.php | 14 +++-- tests/unit/BpeoGroupCalendarScopeTest.php | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/plugins/hc-custom/includes/bp-event-organiser.php b/plugins/hc-custom/includes/bp-event-organiser.php index 97016c0ea..62d7c5abe 100644 --- a/plugins/hc-custom/includes/bp-event-organiser.php +++ b/plugins/hc-custom/includes/bp-event-organiser.php @@ -613,10 +613,16 @@ function hc_custom_bpeo_filter_calendar_query_for_group( $query ) { // how events in non-public groups are stored). $query['bp_group'] = $group_id; - // Access has been checked against the group itself, so do not let EO's - // capability-based 'readable' restriction hide the group's private events - // from its members on top of that. - $query['perm'] = ''; + // Members of the group (and moderators) may see its private events, so for + // them do not let EO's capability-based 'readable' restriction hide those + // on top of the group check. Anyone else viewing a public group keeps the + // role/author check: an event connected to both this public group and a + // private group is a private post and must not leak through here. + if ( current_user_can( 'bp_moderate' ) || groups_is_user_member( get_current_user_id(), $group_id ) ) { + $query['perm'] = ''; + } else { + $query['perm'] = 'readable'; + } return $query; } diff --git a/tests/unit/BpeoGroupCalendarScopeTest.php b/tests/unit/BpeoGroupCalendarScopeTest.php index e11ffaaa9..977cabe55 100644 --- a/tests/unit/BpeoGroupCalendarScopeTest.php +++ b/tests/unit/BpeoGroupCalendarScopeTest.php @@ -260,6 +260,58 @@ public function test_granted_scope_does_not_filter_private_events_by_capability( $this->assertSame( '', $query['perm'] ); } + public function test_anonymous_request_to_public_group_keeps_capability_restriction() { + // A public group's calendar is open to all, but an event connected to + // both this public group and a private group is a private post. Only + // members of the requested group (or moderators) may bypass EO's + // capability check; anonymous visitors must keep it. + $this->setUpGroup( 50, 'public' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( 50, $query['bp_group'] ); + $this->assertSame( 'readable', $query['perm'] ); + } + + public function test_logged_in_non_member_of_public_group_keeps_capability_restriction() { + $this->setUpGroup( 50, 'public' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['group_members'] = array( '7:60' ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( 50, $query['bp_group'] ); + $this->assertSame( 'readable', $query['perm'] ); + } + + public function test_member_of_public_group_bypasses_capability_restriction() { + $this->setUpGroup( 50, 'public' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['group_members'] = array( '7:50' ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( '', $query['perm'] ); + } + + public function test_moderator_bypasses_capability_restriction_on_public_group() { + $this->setUpGroup( 50, 'public' ); + $GLOBALS['_hc_mock']['is_group'] = false; + $GLOBALS['_hc_mock']['current_user_id'] = 7; + $GLOBALS['_hc_mock']['user_can'] = array( 'bp_moderate' => true ); + $_GET['bp_group'] = '50'; + + $query = hc_custom_bpeo_filter_calendar_query_for_group( array( 'perm' => 'readable' ) ); + + $this->assertSame( '', $query['perm'] ); + } + public function test_denied_scope_leaves_capability_restriction_in_place() { $this->setUpGroup( 50, 'private' ); $GLOBALS['_hc_mock']['is_group'] = false; From 36f261e320bd796b755f3c45d3b836590387bc98 Mon Sep 17 00:00:00 2001 From: Martin Paul Eve Date: Sun, 4 Oct 2026 17:36:40 +0100 Subject: [PATCH 8/8] perf(hc-custom): restrict members' private events in SQL, not by ID list The sitewide calendar looked up every private event across a member's groups on each request and embedded the IDs in the main query, so one month's fetch scaled with the groups' whole history (and bpeo disables EO's calendar cache, so navigation repeated it). Express the membership rule as a subquery on the bpeo_event_group term relationships inside the main query, which is then bounded by the calendar's date range. Refs #134 --- .../hc-custom/includes/bp-event-organiser.php | 74 +++++++++---------- .../BpeoSitewideCalendarPrivateEventsTest.php | 62 ++++++---------- 2 files changed, 57 insertions(+), 79 deletions(-) diff --git a/plugins/hc-custom/includes/bp-event-organiser.php b/plugins/hc-custom/includes/bp-event-organiser.php index 62d7c5abe..130bae2cb 100644 --- a/plugins/hc-custom/includes/bp-event-organiser.php +++ b/plugins/hc-custom/includes/bp-event-organiser.php @@ -720,46 +720,23 @@ function hc_custom_bpeo_filter_calendar_event_link_for_group( $link, $event_id = /** Sitewide calendar: members' private group events ***************************/ /** - * IDs of private events connected to any of a user's groups. + * IDs of the groups a user belongs to. * * @param int $user_id User ID. * @return int[] */ -function hc_custom_bpeo_get_member_private_event_ids( $user_id ) { +function hc_custom_bpeo_get_member_group_ids( $user_id ) { $user_id = (int) $user_id; if ( ! $user_id ) { return array(); } $user_groups = groups_get_user_groups( $user_id ); - $group_ids = ! empty( $user_groups['groups'] ) ? array_map( 'intval', (array) $user_groups['groups'] ) : array(); - if ( empty( $group_ids ) ) { + if ( empty( $user_groups['groups'] ) ) { return array(); } - $group_terms = array(); - foreach ( $group_ids as $group_id ) { - $group_terms[] = 'group_' . $group_id; - } - - // Same shape of query bp-event-organiser uses for a group's events. - $q = new WP_Query( array( - 'post_type' => 'event', - 'post_status' => 'private', - 'fields' => 'ids', - 'posts_per_page' => -1, - 'showpastevents' => true, - 'tax_query' => array( - array( - 'taxonomy' => 'bpeo_event_group', - 'field' => 'name', - 'terms' => $group_terms, - 'operator' => 'IN', - ), - ), - ) ); - - return array_values( array_filter( array_map( 'intval', (array) $q->posts ) ) ); + return array_values( array_filter( array_map( 'intval', (array) $user_groups['groups'] ) ) ); } /** @@ -783,47 +760,64 @@ function hc_custom_bpeo_filter_calendar_query_for_member_groups( $query ) { return $query; } - $event_ids = hc_custom_bpeo_get_member_private_event_ids( get_current_user_id() ); - if ( empty( $event_ids ) ) { + $group_ids = hc_custom_bpeo_get_member_group_ids( get_current_user_id() ); + if ( empty( $group_ids ) ) { return $query; } // EO's 'readable' perm would limit private posts to the caller's own, // which, now that read_private_events is no longer handed to everyone, // would hide their groups' events. Admit private posts and let the - // posts_where restriction confine them to the visible IDs. - $query['hc_bpeo_private_event_ids'] = $event_ids; - $query['perm'] = ''; - $query['post_status'] = array_values( array_unique( array_merge( (array) ( $query['post_status'] ?? array( 'publish' ) ), array( 'private' ) ) ) ); + // posts_where restriction confine them to the caller's groups, inside + // the main query so it stays bounded by the calendar's date range. + $query['hc_bpeo_member_group_ids'] = $group_ids; + $query['perm'] = ''; + $query['post_status'] = array_values( array_unique( array_merge( (array) ( $query['post_status'] ?? array( 'publish' ) ), array( 'private' ) ) ) ); return $query; } add_filter( 'eventorganiser_fullcalendar_query', 'hc_custom_bpeo_filter_calendar_query_for_member_groups', 30 ); /** - * Keep private events other than the caller's visible ones out of the query. + * Keep private events outside the caller's groups out of the query. + * + * Expressed as a subquery on the bpeo_event_group term relationships rather + * than a materialised ID list, so the cost follows the main query's date + * range, not the groups' full event history. * * @param string $where SQL WHERE clause. * @param WP_Query $wp_query The query. * @return string */ function hc_custom_bpeo_restrict_private_events_where( $where, $wp_query ) { - $event_ids = $wp_query->get( 'hc_bpeo_private_event_ids' ); - if ( empty( $event_ids ) ) { + $group_ids = $wp_query->get( 'hc_bpeo_member_group_ids' ); + if ( empty( $group_ids ) ) { return $where; } - $event_ids = array_values( array_filter( array_map( 'intval', (array) $event_ids ) ) ); - if ( empty( $event_ids ) ) { + $group_ids = array_values( array_filter( array_map( 'intval', (array) $group_ids ) ) ); + if ( empty( $group_ids ) ) { return $where; } global $wpdb; $posts = $wpdb->posts; $user_id = (int) get_current_user_id(); - $id_list = implode( ',', $event_ids ); - $where .= " AND ( {$posts}.post_status <> 'private' OR {$posts}.post_author = {$user_id} OR {$posts}.ID IN ({$id_list}) )"; + // Term names are 'group_' and built from integers only, so they need + // no further escaping. + $term_names = array(); + foreach ( $group_ids as $group_id ) { + $term_names[] = "'group_{$group_id}'"; + } + $term_list = implode( ',', $term_names ); + + $in_member_groups = "SELECT tr.object_id FROM {$wpdb->term_relationships} tr" + . " INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id" + . " INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id" + . " WHERE tt.taxonomy = 'bpeo_event_group' AND t.name IN ({$term_list})"; + + $where .= " AND ( {$posts}.post_status <> 'private' OR {$posts}.post_author = {$user_id} OR {$posts}.ID IN ({$in_member_groups}) )"; return $where; } diff --git a/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php b/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php index 33ae3fa4b..438ec0c27 100644 --- a/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php +++ b/tests/unit/BpeoSitewideCalendarPrivateEventsTest.php @@ -16,41 +16,20 @@ class BpeoSitewideCalendarPrivateEventsTest extends TestCase { protected function setUp(): void { $GLOBALS['_hc_mock'] = array(); $GLOBALS['hc_test'] = array(); - $GLOBALS['wpdb'] = (object) array( 'posts' => 'wp_posts' ); + $GLOBALS['wpdb'] = (object) array( + 'posts' => 'wp_posts', + 'term_relationships' => 'wp_term_relationships', + 'term_taxonomy' => 'wp_term_taxonomy', + 'terms' => 'wp_terms', + ); } /** - * Log in user 3 as a member of groups 50 and 60, whose private events are - * 7 and 9. + * Log in user 3 as a member of groups 50 and 60. */ private function setUpMemberWithPrivateEvents() { $GLOBALS['_hc_mock']['current_user_id'] = 3; $GLOBALS['_hc_mock']['user_groups'] = array( 3 => array( 50, 60 ) ); - $GLOBALS['_hc_mock']['wp_query_callback'] = function ( $args ) { - return array( 7, 9 ); - }; - } - - // ----------------------------------------------------------------------- - // Which private events a member may see. - // ----------------------------------------------------------------------- - - public function test_member_private_event_ids_come_from_their_groups() { - $this->setUpMemberWithPrivateEvents(); - - $this->assertSame( array( 7, 9 ), hc_custom_bpeo_get_member_private_event_ids( 3 ) ); - } - - public function test_user_in_no_groups_has_no_private_event_ids() { - $GLOBALS['_hc_mock']['wp_query_callback'] = function ( $args ) { - return array( 7, 9 ); - }; - - $this->assertSame( array(), hc_custom_bpeo_get_member_private_event_ids( 4 ) ); - } - - public function test_anonymous_user_has_no_private_event_ids() { - $this->assertSame( array(), hc_custom_bpeo_get_member_private_event_ids( 0 ) ); } // ----------------------------------------------------------------------- @@ -63,9 +42,8 @@ public function test_unscoped_query_unchanged_for_anonymous_caller() { $this->assertSame( $in, hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ) ); } - public function test_unscoped_query_unchanged_for_member_without_private_group_events() { - $GLOBALS['_hc_mock']['current_user_id'] = 3; - $GLOBALS['_hc_mock']['user_groups'] = array( 3 => array( 50 ) ); + public function test_unscoped_query_unchanged_for_user_in_no_groups() { + $GLOBALS['_hc_mock']['current_user_id'] = 4; $in = array( 'perm' => 'readable', 'post_status' => array( 'publish', 'private' ) ); @@ -77,7 +55,7 @@ public function test_unscoped_query_admits_members_private_group_events() { $out = hc_custom_bpeo_filter_calendar_query_for_member_groups( array( 'perm' => 'readable', 'post_status' => array( 'publish', 'private' ) ) ); - $this->assertSame( array( 7, 9 ), $out['hc_bpeo_private_event_ids'] ); + $this->assertSame( array( 50, 60 ), $out['hc_bpeo_member_group_ids'] ); $this->assertSame( '', $out['perm'] ); $this->assertContains( 'private', (array) $out['post_status'] ); $this->assertContains( 'publish', (array) $out['post_status'] ); @@ -95,7 +73,7 @@ public function test_unscoped_query_unchanged_for_caller_whose_role_reads_privat $out = hc_custom_bpeo_filter_calendar_query_for_member_groups( $in ); $this->assertSame( $in, $out ); - $this->assertArrayNotHasKey( 'hc_bpeo_private_event_ids', $out ); + $this->assertArrayNotHasKey( 'hc_bpeo_member_group_ids', $out ); } public function test_group_scoped_query_is_left_alone() { @@ -132,25 +110,31 @@ public function test_where_clause_untouched_without_visible_private_events() { $this->assertSame( ' AND 1=1', hc_custom_bpeo_restrict_private_events_where( ' AND 1=1', $q ) ); } - public function test_where_clause_limits_private_posts_to_visible_ids_and_own_posts() { + public function test_where_clause_limits_private_posts_to_member_groups_and_own_posts() { + // The restriction must be expressed against the group connection + // taxonomy inside the main query, so it is bounded by that query's + // date range rather than by the groups' whole event history. $GLOBALS['_hc_mock']['current_user_id'] = 3; - $q = new WP_Query( array( 'post_type' => 'event', 'hc_bpeo_private_event_ids' => array( 7, 9 ) ) ); + $q = new WP_Query( array( 'post_type' => 'event', 'hc_bpeo_member_group_ids' => array( 50, 60 ) ) ); $where = hc_custom_bpeo_restrict_private_events_where( ' AND 1=1', $q ); $this->assertStringStartsWith( ' AND 1=1', $where ); $this->assertStringContainsString( "wp_posts.post_status <> 'private'", $where ); - $this->assertStringContainsString( 'wp_posts.ID IN (7,9)', $where ); $this->assertStringContainsString( 'wp_posts.post_author = 3', $where ); + $this->assertStringContainsString( "taxonomy = 'bpeo_event_group'", $where ); + $this->assertStringContainsString( "'group_50','group_60'", $where ); + $this->assertStringContainsString( 'wp_term_relationships', $where ); } - public function test_where_clause_only_ever_contains_integer_ids() { + public function test_where_clause_only_ever_contains_integer_group_ids() { $GLOBALS['_hc_mock']['current_user_id'] = 3; - $q = new WP_Query( array( 'hc_bpeo_private_event_ids' => array( '7', "9) OR (1=1", 'x', 0 ) ) ); + $q = new WP_Query( array( 'hc_bpeo_member_group_ids' => array( '50', "60) OR (1=1", 'x', 0 ) ) ); $where = hc_custom_bpeo_restrict_private_events_where( '', $q ); - $this->assertStringContainsString( 'wp_posts.ID IN (7,9)', $where ); + $this->assertStringContainsString( "'group_50','group_60'", $where ); $this->assertStringNotContainsString( '1=1', $where ); + $this->assertStringNotContainsString( "'group_0'", $where ); } }