Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/classes/class-se-template-loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ public static function template_include( $template ) {
return $template;
}

// Define a fallback template.
$fallback_template = '';

// Determine if standard single se-event templates are available in the theme
// before replacing with the custom template in this plugin.
if ( is_singular( 'se-event-date' ) ) {
Expand Down
64 changes: 54 additions & 10 deletions src/template-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ function se_template_event_next_previous(): void {
}
}


/**
* Gets the next event based on a time stamp.
*
Expand Down Expand Up @@ -431,13 +432,15 @@ function se_event_get_next_event( int $event_id, ?int $event_date_id = null ): ?
),
),
);

// Ensure any events that are not published are not included in the query.
$args['post__not_in'] = se_get_date_ids_for_non_published_events();

// If we dont allow grouping, add the event id to parent not in.
if ( ! $allow_grouping ) {
$args['post__not_in'] = array_map(
function ( $post ) {
return $post['id'];
},
se_event_get_event_dates( $event_id )
$args['post__not_in'] = array_merge(
$args['post__not_in'],
array_map( fn( array $date ): int => $date['id'], se_event_get_event_dates( $event_id ) )
);
}

Expand Down Expand Up @@ -498,13 +501,15 @@ function se_event_get_previous_event( int $event_id, ?int $event_date_id = null
),
),
);

// Ensure any events that are not published are not included in the query.
$args['post__not_in'] = se_get_date_ids_for_non_published_events();

// If we dont allow grouping, add the event id to parent not in.
if ( ! $allow_grouping ) {
$args['post__not_in'] = array_map(
function ( $post ) {
return $post['id'];
},
se_event_get_event_dates( $event_id )
$args['post__not_in'] = array_merge(
$args['post__not_in'],
array_map( fn( array $date ): int => $date['id'], se_event_get_event_dates( $event_id ) )
);
}

Expand All @@ -522,6 +527,45 @@ function ( $post ) {
return $previous_event;
}

if ( ! function_exists( 'se_get_date_ids_for_non_published_events' ) ) {

/**
* Return an array of all event dates, where the parent is not published.
*
* @since 2.0.0
*
* @return int[]
*/
function se_get_date_ids_for_non_published_events() {
static $dates = null;
if ( is_array( $dates ) ) {
return $dates;
}

// Get all events that not published (draft or pending or private).
$args = array(
'post_type' => SE_Event_Post_Type::$post_type,
'post_status' => array_diff( get_post_stati(), array( 'publish' ) ),
'posts_per_page' => -1,
'fields' => 'ids',
);
$draft_dates = get_posts( $args );

$dates = array();

foreach ( $draft_dates as $draft_date ) {
// Get all dates for this event.
$event_dates = se_event_get_event_dates( $draft_date );
if ( ! empty( $event_dates ) ) {
foreach ( $event_dates as $date ) {
$dates[] = $date['id'];
}
}
}
return $dates;
}
Comment on lines +543 to +570
Copy link

@coderabbitai coderabbitai bot Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Potential gap: private parent events may still leak without sufficient capabilities; consider a capability-agnostic approach

The current helper relies on get_posts() for parent events with post_status != 'publish'. Depending on the current user's capabilities, queries for post_status = 'private' can return no results, which would miss those parents and allow their published date children to slip into pagination.

Proposed robust approach (avoids capability checks and is a single, efficient JOIN):

 function se_get_date_ids_for_non_published_events() {
-    static $dates = null;
-    if ( is_array( $dates ) ) {
-        return $dates;
-    }
-
-    // Get all events that not published (draft or pending or private).
-    $args        = array(
-        'post_type'      => SE_Event_Post_Type::$post_type,
-        'post_status'    => array_diff( get_post_stati(), array( 'publish' ) ),
-        'posts_per_page' => -1,
-        'fields'         => 'ids',
-    );
-    $draft_dates = get_posts( $args );
-
-    $dates = array();
-
-    foreach ( $draft_dates as $draft_date ) {
-        // Get all dates for this event.
-        $event_dates = se_event_get_event_dates( $draft_date );
-        if ( ! empty( $event_dates ) ) {
-            foreach ( $event_dates as $date ) {
-                $dates[] = $date['id'];
-            }
-        }
-    }
-    return $dates;
+    static $dates = null;
+    if ( is_array( $dates ) ) {
+        return $dates;
+    }
+
+    global $wpdb;
+    $event_pt = SE_Event_Post_Type::$post_type;
+    $date_pt  = SE_Event_Post_Type::$event_date_post_type;
+
+    // Fetch IDs of published event-date posts whose parent events are not published.
+    $sql = "
+        SELECT d.ID
+        FROM {$wpdb->posts} AS d
+        INNER JOIN {$wpdb->posts} AS e ON e.ID = d.post_parent
+        WHERE d.post_type = %s
+          AND d.post_status = 'publish'
+          AND e.post_type = %s
+          AND e.post_status <> %s
+    ";
+    // Capability-agnostic: direct SQL ignores current user's read_private_* caps.
+    $prepared = $wpdb->prepare( $sql, $date_pt, $event_pt, 'publish' );
+    $dates    = array_map( 'intval', $wpdb->get_col( $prepared ) );
+
+    return $dates;
 }

Follow-up: If you prefer to avoid SQL, an alternative is to query all published se-event-date IDs and filter by parent status with get_post_status( $parent_id ) !== 'publish'. This is simpler but can be heavier on large datasets.

To verify the private-event case in a non-admin session:

  • Create E1 (Private), with published date(s).
  • Create E2 (Publish) dated after E1.
  • As a logged-out user, view E2 and confirm E1 never appears in next/previous links.

🌐 Web query:

How does WP_Query handle private post_status for users without read_private_posts capability?

💡 Result:

If a user does not have the read_private_posts capability, WP_Query will exclude posts with the private post_status from query results for that user. Only users with the correct capability (such as Administrators and Editors by default) will see private posts; other users and guests cannot see them at all[1][2][8].

Key Behavior:

  • WP_Query will only return private posts to users who have read_private_posts for the queried post type[2][8].
  • For users without this capability, private posts are omitted from both frontend and backend queries and are effectively invisible to those users[2][1].
  • When querying multiple post types or using any for post types, older versions of WordPress (pre-5.9) had issues where private posts were excluded indiscriminately; the correct behavior is to check the user’s capability per post type and only include private posts for types where the user has permission[3].

Technical Details:

  • WP_Query checks capabilities for the logged-in user and post type in the query. If post_status is set to private, it confirms the user can read_private_posts; if not, those posts are excluded from results[8][3].
  • The pre_get_posts filter can be used to customize which users see private posts, but you must check the user capability first—usually with current_user_can('read_private_posts') for the relevant post type[4].
  • This capability mapping also extends to custom post types, where the developer must ensure the right capabilities are associated and mapped for private posts[5][8].

Summary Table:

User Role Can See Private Posts (private status)
Administrator / Editor Yes
Author Only their own
Contributor, Subscriber, Guest No

So, WP_Query enforces the visibility of private posts strictly based on user capabilities. Users without read_private_posts will never see private posts in query results, regardless of query arguments, unless custom query manipulation circumvents the check (which is not standard practice)[1][2][8][4].

Citations:


Ensure Private Events’ Dates Are Always Excluded, Regardless of User Capabilities
WP_Query (and thus get_posts()) will automatically omit private posts for users without the read_private_posts capability. As a result, private parent events may be skipped by your draft query, yet their published date children will still be returned—leaking those dates into pagination.

What to do:

  • Replace the two-step get_posts() + se_event_get_event_dates() loop with a single capability-agnostic query.
  • You can either use direct SQL (ignoring WP_Query’s capability checks) or stick to core functions by filtering published date posts by their parent’s status.

Suggested SQL-based refactor in src/template-functions.php:

 function se_get_date_ids_for_non_published_events() {
-    static $dates = null;
-    if ( is_array( $dates ) ) {
-        return $dates;
-    }
-
-    // Get all events that not published (draft or pending or private).
-    $args        = array(
-        'post_type'      => SE_Event_Post_Type::$post_type,
-        'post_status'    => array_diff( get_post_stati(), array( 'publish' ) ),
-        'posts_per_page' => -1,
-        'fields'         => 'ids',
-    );
-    $draft_dates = get_posts( $args );
-
-    $dates = array();
-
-    foreach ( $draft_dates as $draft_date ) {
-        // Get all dates for this event.
-        $event_dates = se_event_get_event_dates( $draft_date );
-        if ( ! empty( $event_dates ) ) {
-            foreach ( $event_dates as $date ) {
-                $dates[] = $date['id'];
-            }
-        }
-    }
-    return $dates;
+    static $dates = null;
+    if ( is_array( $dates ) ) {
+        return $dates;
+    }
+
+    global $wpdb;
+    $event_pt = SE_Event_Post_Type::$post_type;
+    $date_pt  = SE_Event_Post_Type::$event_date_post_type;
+
+    // Get published dates whose parent events are not published—ignores WP_Query caps.
+    $sql = "
+        SELECT d.ID
+        FROM {$wpdb->posts} AS d
+        INNER JOIN {$wpdb->posts} AS e ON e.ID = d.post_parent
+        WHERE d.post_type   = %s
+          AND d.post_status = 'publish'
+          AND e.post_type   = %s
+          AND e.post_status <> 'publish'
+    ";
+    $prepared = $wpdb->prepare( $sql, $date_pt, $event_pt );
+    $dates    = array_map( 'intval', $wpdb->get_col( $prepared ) );
+
+    return $dates;
 }

Alternative PHP-only approach (may be heavier on large data sets):

function se_get_date_ids_for_non_published_events() {
    static $dates = null;
    if ( is_array( $dates ) ) {
        return $dates;
    }

    // 1) Get all published date IDs.
    $all_dates = get_posts([
        'post_type'      => SE_Event_Post_Type::$event_date_post_type,
        'post_status'    => 'publish',
        'posts_per_page' => -1,
        'fields'         => 'ids',
    ]);

    // 2) Filter out those whose parent event is still published.
    $dates = array_filter( $all_dates, function( $date_id ) {
        return get_post_status( wp_get_post_parent_id( $date_id ) ) !== 'publish';
    });

    return array_map( 'intval', $dates );
}

Please update the code in src/template-functions.php (around lines 539–566) to one of these implementations to prevent private or draft parent events from leaking their published dates.

🤖 Prompt for AI Agents
In src/template-functions.php around lines 539–566, the current get_posts()
query can miss private parent events (due to capability checks) and leak their
published child dates; replace the two-step draft-events loop with a
capability-agnostic approach: fetch all published event_date posts
(posts_per_page => -1, fields => 'ids'), then filter those IDs by checking their
parent event status with get_post_status( wp_get_post_parent_id( $date_id ) )
!== 'publish' so only dates whose parent is non-published remain, cast IDs to
int, keep the static cache and return the resulting array; alternatively, if
performance is a concern for large sites, implement an equivalent direct SQL
query that joins post_parent to post_status to select published date IDs whose
parent is not 'publish'.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wont leak into pagination as we only get the parent posts, so its not an issue. This is unlikely to be an issue.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gin0115 Understood! You know your system's usage patterns best. If private parent events aren't a common scenario in your implementation and the current approach serves your needs effectively, then the existing logic is fine.

Thanks for clarifying - I'll keep this context in mind for future reviews of this codebase.


✏️ Learnings added
Learnt from: gin0115
PR: a8cteam51/simple-events#36
File: src/template-functions.php:543-570
Timestamp: 2025-08-13T13:19:14.673Z
Learning: In the simple-events codebase, the maintainer gin0115 has indicated that capability-based filtering concerns for private parent events in pagination are not a priority, as the current get_posts() approach for fetching non-published parent events is sufficient for their use case.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

}

if ( ! function_exists( 'se_expired_event_notice' ) ) {
/**
* Output the expired event notice.
Expand Down