Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
18 changes: 15 additions & 3 deletions includes/content-gate/class-access-rules.php
Original file line number Diff line number Diff line change
Expand Up @@ -355,11 +355,22 @@ public static function get_subscription_products_options() {
* Whether the user has an active subscription for one of the given products.
* Also checks if the user is a member of a group subscription with the required products.
*
* Note: `$strict` only constrains the built-in ownership / group-membership checks.
* The `newspack_access_rules_has_active_subscription` filter is always applied and
* its return value is the final result, so a third-party filter callback can grant
* access even when `$strict` is true. Filter authors should opt in to the 4th `$strict`
* arg (`accepted_args` >= 4) and respect it — e.g., short-circuit and return
* `$has_subscription` unchanged when `$strict` is true and the access claim isn't
* strictly an owned subscription. Otherwise callers using `$strict` to distinguish
* owner-vs-member access (e.g., `Content_Gate` source labels) may misclassify
* filter-granted access as local ownership.
*
* @param int $user_id User ID.
* @param array $product_ids Required product IDs.
* @param bool $strict If true, only consider active subscriptions owned by $user_id (ignore group subscription memberships).
* @return bool
*/
public static function has_active_subscription( $user_id, $product_ids ) {
public static function has_active_subscription( $user_id, $product_ids, $strict = false ) {
$has_subscription = false;

// Check user's own subscriptions.
Expand All @@ -368,7 +379,7 @@ public static function has_active_subscription( $user_id, $product_ids ) {
}

// Check group subscriptions the user is a member of.
if ( ! $has_subscription && function_exists( 'wcs_get_subscription' ) ) {
if ( ! $strict && ! $has_subscription && function_exists( 'wcs_get_subscription' ) ) {
$group_subscriptions = Group_Subscription::get_group_subscriptions_for_user( $user_id );
foreach ( $group_subscriptions as $subscription ) {
if ( ! $subscription || ! $subscription->has_status( WooCommerce_Connection::ACTIVE_SUBSCRIPTION_STATUSES ) ) {
Expand All @@ -395,8 +406,9 @@ public static function has_active_subscription( $user_id, $product_ids ) {
* @param bool $has_subscription Whether the user has an active subscription.
* @param int $user_id User ID.
* @param array $product_ids Required product IDs.
* @param bool $strict If true, only consider active subscriptions owned by $user_id (ignore group subscription memberships).
*/
return apply_filters( 'newspack_access_rules_has_active_subscription', $has_subscription, $user_id, $product_ids );
return apply_filters( 'newspack_access_rules_has_active_subscription', $has_subscription, $user_id, $product_ids, $strict );
Comment thread
dkoo marked this conversation as resolved.
}

/**
Expand Down
129 changes: 123 additions & 6 deletions includes/content-gate/class-institution.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,113 @@ public static function evaluate( $user_id, $institution_ids ) {
return false;
}

/**
* Per-request cache of [inst_id => decoded_name] maps.
*
* @var array<string,array<int,string>>
*/
private static $names_cache = [];

/**
* Reset the per-request matching-names cache.
*
* Tests, CLI workers, and invalidation hooks call this to bust the memoization in
* `get_matching_names_for_user()` / `get_matching_ids_for_user()`. Distinct from
* `invalidate_cache()`, which clears the underlying institutions transient.
*/
public static function reset_matching_cache() {
self::$names_cache = [];
}

/**
* Get the sorted, deduplicated names of institutions whose rules a user matches.
*
* Memoized per request via {@see self::get_matching_map_for_user()} — see that helper
* for cache scope, the IP/cookie context dependency, and invalidation.
*
* @param int $user_id User ID.
* @param array|null $institution_filter Optional list of institution post IDs. If non-empty, only
* institutions whose ID is in the list are considered.
* Pass null or an empty array to scan every cached institution.
*
* @return string[] Sorted, deduplicated institution names.
*/
public static function get_matching_names_for_user( $user_id, $institution_filter = null ) {
$map = self::get_matching_map_for_user( $user_id, $institution_filter );
$names = array_values( array_unique( array_values( $map ) ) );
sort( $names, SORT_NATURAL | SORT_FLAG_CASE );
return $names;
}

/**
* Get the IDs of institutions whose rules a user matches.
*
* Returns institution post IDs. Shares the per-request cache with
* {@see self::get_matching_names_for_user()}. Suitable for callers that want a stable,
* non-PII identifier (e.g., GA4 anonymized labels) and don't need the display name.
*
* @param int $user_id User ID.
* @param array|null $institution_filter Optional list of institution post IDs (same semantics
* as {@see self::get_matching_names_for_user()}).
*
* @return int[] Sorted institution post IDs.
*/
public static function get_matching_ids_for_user( $user_id, $institution_filter = null ) {
$ids = array_keys( self::get_matching_map_for_user( $user_id, $institution_filter ) );
sort( $ids, SORT_NUMERIC );
return $ids;
}

/**
* Build the [inst_id => decoded_name] map for the user, memoized per request.
*
* Cache key includes `get_current_user_id()` because `user_matches_institution()`'s IP
* branch is context-dependent on the current visitor (see the comment in that method).
* Without that the same `$user_id` could legitimately resolve to different sets across
* requests in a long-running worker that swaps the current user.
*
* IDs are read with `get_post_field( 'post_title', ... )` rather than `get_the_title( ... )`
* so the value going to GA4/ESP is a raw post title, not one that's been through
* `the_title` filters (texturization, third-party plugins) that can introduce entities
* or markup.
*
* @param int $user_id User ID.
* @param array|null $institution_filter Optional list of institution post IDs.
*
* @return array<int,string> Map of institution post ID to decoded post title.
*/
private static function get_matching_map_for_user( $user_id, $institution_filter = null ) {
$user_id = (int) $user_id;

// Normalize the filter so [], null, and unsorted/duplicate inputs share a cache key.
$normalized_filter = is_array( $institution_filter ) && ! empty( $institution_filter )
? array_values( array_unique( array_map( 'absint', $institution_filter ) ) )
: null;
if ( null !== $normalized_filter ) {
sort( $normalized_filter, SORT_NUMERIC );
}
// Include the current user in the cache key because the IP-rule branch of
// user_matches_institution() short-circuits when $user_id !== get_current_user_id().
$cache_key = $user_id . '|' . get_current_user_id() . '|' . ( null === $normalized_filter ? '' : implode( ',', $normalized_filter ) );
if ( isset( self::$names_cache[ $cache_key ] ) ) {
return self::$names_cache[ $cache_key ];
}

$map = [];
foreach ( self::get_cached_institutions() as $inst_id => $rules ) {
$inst_id = (int) $inst_id;
if ( null !== $normalized_filter && ! in_array( $inst_id, $normalized_filter, true ) ) {
continue;
}
if ( self::user_matches_institution( $user_id, $rules ) ) {
$map[ $inst_id ] = html_entity_decode( (string) \get_post_field( 'post_title', $inst_id ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
}
}

self::$names_cache[ $cache_key ] = $map;
return $map;
}

/**
* Check if a user matches an institution's rules (OR logic).
*
Expand All @@ -254,12 +361,22 @@ public static function user_matches_institution( $user_id, $rules, $uncached = f

if ( ! empty( $rules['ip_range'] ) ) {
// IP evaluation is page-cache-safe only when the response would be uncached anyway:
// the caller flagged it as such, the visitor is logged in, or the visitor carries
// the IP-access bypass cookie. A first-time anonymous on-campus visitor landing
// directly on a gated post matches none of these and will see the gate — they must
// first complete the IP check at /institutional-access (or ?institutional-access=1)
// to set the cookie before subsequent gated requests can evaluate their IP.
$is_uncached = $uncached || ! empty( $user_id ) || IP_Access_Rule::is_cookie_set();
// the caller flagged it as such, the *current visitor* is logged in (so the IP we
// would read is theirs), or the visitor carries the IP-access bypass cookie. We
// require $user_id === get_current_user_id() to avoid attributing the requestor's
// IP to a different user during background metadata sync (admin/cron/webhook). A
// first-time anonymous on-campus visitor landing directly on a gated post matches
// none of these and will see the gate — they must first complete the IP check at
// /institutional-access (or ?institutional-access=1) to set the cookie before
// subsequent gated requests can evaluate their IP.
//
// Caveat: the IP_Access_Rule::is_cookie_set() disjunct accepts any non-current
// $user_id when the *current visitor* carries the cache-bypass cookie, so a
// cookie-bearing on-campus visitor can still cause the current request's IP to
// be matched against another user's institution range. This is a narrow,
// pre-existing edge case (the cookie is only set after the visitor has already
// passed an institutional-access check on this site).
$is_uncached = $uncached || ( ! empty( $user_id ) && (int) $user_id === get_current_user_id() ) || IP_Access_Rule::is_cookie_set();
Comment thread
dkoo marked this conversation as resolved.
if ( $is_uncached && IP_Access_Rule::ip_matches_ranges( IP_Access_Rule::get_visitor_ip(), $rules['ip_range'] ) ) {
return true;
}
Expand Down
7 changes: 6 additions & 1 deletion includes/plugins/class-teams-for-memberships.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Newspack\Reader_Activation\Sync\Woocommerce as Sync_WooCommerce;
use Newspack\Reader_Activation\Sync\Metadata as Sync_Metadata;
use Newspack\Reader_Activation\Contact_Sync;
use Newspack\Reader_Activation\Integrations;

/**
* Main class.
Expand Down Expand Up @@ -187,7 +188,11 @@ public static function handle_esp_sync_contact( $contact ) {
return $contact;
}

$filtered_enabled_fields = Sync_Metadata::filter_enabled_fields( [ 'woo_team' ] );
$esp = Integrations::get_integration( 'esp' );
if ( ! $esp ) {
return $contact;
}
$filtered_enabled_fields = $esp->filter_enabled_outgoing_fields( [ 'woo_team' ] );

if ( empty( $contact['email'] ) ) {
return $contact;
Expand Down
50 changes: 50 additions & 0 deletions includes/plugins/google-site-kit/class-googlesitekit.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,14 @@ function( $category ) {
// If reader has any currently active non-donation subscriptions.
$params['is_subscriber'] = empty( $reader_data['active_subscriptions'] ) ? 'no' : 'yes';

// Content access groups: anonymized identifiers for the user's active group
// subscriptions and matching institutions. See get_user_group_labels() for
// why we send IDs to GA4 rather than the human-readable names.
if ( Content_Gate::is_newspack_feature_enabled() ) {
$group_labels = self::get_user_group_labels( $current_user );
$params['group'] = empty( $group_labels ) ? 'none' : implode( ', ', $group_labels );
}

/**
* Filters the custom parameters passed to GA4.
*
Expand All @@ -268,6 +276,48 @@ function( $category ) {
return apply_filters( 'newspack_ga4_custom_parameters', $params );
}

/**
* Build the GA4 `group` parameter value for a user.
*
* The value is a sorted, comma-delimited list of anonymized identifiers for
* active group subscriptions the user owns or is a member of, plus institutions
* whose rules the user matches via any means.
*
* We emit anonymized IDs (`Group {sub_id}`, `Institution {inst_id}`) rather than
* publisher-facing display names because the unnamed-group fallback in
* `Group_Subscription_Settings` synthesizes a name from the owner's billing full
* name — sending that to GA4 would leak PII for every member of an unnamed group.
* The ESP path keeps the human-readable names; only the GA4 surface is anonymized.
*
* Both lookups are delegated to per-request-memoized helpers in `Group_Subscription`
* and `Institution`, so repeat calls within the same request are cheap. Memoization
* is deliberately request-scoped because the institution branch is per-visitor.
*
* @param \WP_User $user The user to inspect.
* @return string[] Sorted, deduplicated anonymized labels.
*/
Comment thread
dkoo marked this conversation as resolved.
private static function get_user_group_labels( $user ) {
if ( ! $user || ! $user->ID ) {
return [];
}
// Match the framing of the surrounding params (`is_reader`, `is_subscriber`):
// only attribute groups to actual readers, not admins/editors.
if ( ! Reader_Activation::is_user_reader( $user ) ) {
return [];
}
$user_id = (int) $user->ID;

$labels = [];
foreach ( Group_Subscription::get_group_ids_for_user( $user_id ) as $sub_id ) {
$labels[] = 'Group ' . $sub_id;
}
foreach ( Institution::get_matching_ids_for_user( $user_id ) as $inst_id ) {
$labels[] = 'Institution ' . $inst_id;
}
sort( $labels, SORT_NATURAL | SORT_FLAG_CASE );
return $labels;
}

/**
* Filter the GA config to add custom parameters.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,13 @@ public static function inject_member_group_subscriptions( $subscriptions, $user_
if ( ! function_exists( 'is_account_page' ) || ! \is_account_page() ) {
return $subscriptions;
}
// Don't add Group Subscription features to My Account when Woo Memberships
// is active. TODO: Remove this once Access Control is fully released.
// Mirrors the suppression that used to live in Group_Subscription::is_group_subscription(),
// preserved here at the UI layer now that data-layer callers always see the canonical state.
if ( Memberships::is_active() ) {
return $subscriptions;
}
$existing_ids = array_keys( $subscriptions );
$group_subscriptions = Group_Subscription::get_group_subscriptions_for_user( $user_id );
foreach ( $group_subscriptions as $group_subscription ) {
Expand Down
Loading
Loading