Skip to content

Commit aa182c9

Browse files
authored
feat(content-gate): grouped access rules evaluation and normalization (#4435)
1 parent 6425dbf commit aa182c9

7 files changed

Lines changed: 504 additions & 24 deletions

File tree

includes/content-gate/class-access-rules.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,87 @@ public static function evaluate_rule( $rule_slug, $args = null, $user_id = null
168168
return call_user_func( $rule['callback'], $user_id, $args );
169169
}
170170

171+
/**
172+
* Evaluate access rules with OR logic between groups and AND logic within groups.
173+
*
174+
* Rules structure: [ [ rule1, rule2 ], [ rule3, rule4 ] ]
175+
* - Groups use OR logic: reader must pass at least one group
176+
* - Rules within a group use AND logic: reader must pass all rules in the group
177+
*
178+
* @param array $access_rules The access rules (array of groups, each group is an array of rules).
179+
*
180+
* @return bool True if access is granted, false if restricted.
181+
*/
182+
public static function evaluate_rules( $access_rules ) {
183+
if ( empty( $access_rules ) ) {
184+
return true;
185+
}
186+
187+
// Normalize legacy flat rules structure to grouped format.
188+
$access_rules = self::normalize_rules( $access_rules );
189+
190+
// Evaluate each group with OR logic - if any group passes, grant access.
191+
foreach ( $access_rules as $group ) {
192+
if ( self::evaluate_rules_group( $group ) ) {
193+
return true;
194+
}
195+
}
196+
197+
// No group passed - restrict access.
198+
return false;
199+
}
200+
201+
/**
202+
* Evaluate a single group of access rules with AND logic.
203+
*
204+
* @param array $group Array of rules in the group.
205+
*
206+
* @return bool True if all rules in the group pass, false otherwise.
207+
*/
208+
private static function evaluate_rules_group( $group ) {
209+
if ( empty( $group ) || ! is_array( $group ) ) {
210+
return true;
211+
}
212+
213+
foreach ( $group as $rule ) {
214+
if ( ! isset( $rule['slug'] ) ) {
215+
continue;
216+
}
217+
if ( ! self::evaluate_rule( $rule['slug'], $rule['value'] ?? null ) ) {
218+
return false;
219+
}
220+
}
221+
222+
return true;
223+
}
224+
225+
/**
226+
* Normalize access rules to grouped format.
227+
*
228+
* Converts legacy flat rules [ rule1, rule2 ] to grouped format [ [ rule1, rule2 ] ].
229+
*
230+
* @param array $access_rules The access rules.
231+
*
232+
* @return array Normalized access rules in grouped format.
233+
*/
234+
public static function normalize_rules( $access_rules ) {
235+
if ( empty( $access_rules ) ) {
236+
return [];
237+
}
238+
239+
// Check if already in grouped format (array of arrays with rules).
240+
// A grouped format has arrays as first-level elements.
241+
// A flat format has rule objects (with 'slug' key) as first-level elements.
242+
$first_element = reset( $access_rules );
243+
if ( is_array( $first_element ) && ! isset( $first_element['slug'] ) ) {
244+
// Already in grouped format.
245+
return $access_rules;
246+
}
247+
248+
// Convert flat format to single group.
249+
return [ $access_rules ];
250+
}
251+
171252
/**
172253
* Get subscriptions eligible for access rules.
173254
*

includes/content-gate/class-content-gate.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,11 @@ public static function get_custom_access_settings( $gate_id ) {
767767
$custom_access = [];
768768
}
769769

770+
$access_rules = isset( $custom_access['access_rules'] ) ? $custom_access['access_rules'] : [];
771+
772+
// Normalize legacy flat rules to grouped format.
773+
$access_rules = Access_Rules::normalize_rules( $access_rules );
774+
770775
$default_metering = [
771776
'enabled' => false,
772777
'count' => 0,
@@ -776,7 +781,7 @@ public static function get_custom_access_settings( $gate_id ) {
776781
return [
777782
'active' => isset( $custom_access['active'] ) ? (bool) $custom_access['active'] : false,
778783
'metering' => isset( $custom_access['metering'] ) ? $custom_access['metering'] : $default_metering,
779-
'access_rules' => isset( $custom_access['access_rules'] ) ? $custom_access['access_rules'] : [],
784+
'access_rules' => $access_rules,
780785
'gate_layout_id' => isset( $custom_access['gate_layout_id'] ) ? (int) $custom_access['gate_layout_id'] : 0,
781786
];
782787
}

includes/content-gate/class-content-restriction-control.php

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77

88
namespace Newspack;
99

10-
use Newspack\Access_Rules;
11-
1210
/**
1311
* Main class.
1412
*/
@@ -211,14 +209,9 @@ public static function is_post_restricted( $is_post_restricted, $post_id = null
211209
// If custom_access mode is active.
212210
if ( ! $is_restricted && ! empty( $gate['custom_access']['active'] ) ) {
213211
$access_rules = $gate['custom_access']['access_rules'] ?? [];
214-
if ( ! empty( $access_rules ) ) {
215-
foreach ( $access_rules as $rule ) {
216-
if ( ! Access_Rules::evaluate_rule( $rule['slug'], $rule['value'] ?? null ) ) {
217-
$is_restricted = true;
218-
$gate_layout_id = $gate['custom_access']['gate_layout_id'] ?? $gate['id'];
219-
break;
220-
}
221-
}
212+
if ( ! empty( $access_rules ) && ! Access_Rules::evaluate_rules( $access_rules ) ) {
213+
$is_restricted = true;
214+
$gate_layout_id = $gate['custom_access']['gate_layout_id'] ?? $gate['id'];
222215
}
223216
}
224217

includes/wizards/audience/class-audience-content-gates.php

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -369,10 +369,13 @@ public function register_api_endpoints() {
369369
'access_rules' => [
370370
'type' => 'array',
371371
'items' => [
372-
'type' => 'object',
373-
'properties' => [
374-
'slug' => [ 'type' => 'string' ],
375-
'value' => [ 'type' => 'mixed' ],
372+
'type' => 'array',
373+
'items' => [
374+
'type' => 'object',
375+
'properties' => [
376+
'slug' => [ 'type' => 'string' ],
377+
'value' => [ 'type' => 'mixed' ],
378+
],
376379
],
377380
],
378381
],
@@ -472,22 +475,81 @@ public function sanitize_metering( $metering ) {
472475
* @param array $rules The rules.
473476
* @param string $type The type of rules to sanitize.
474477
*
475-
* @return array The sanitized access rules.
478+
* @return array The sanitized rules.
476479
*/
477480
public function sanitize_rules( $rules, $type = 'access' ) {
478-
$sanitized_rules = [];
479481
if ( ! is_array( $rules ) ) {
480-
return $sanitized_rules;
482+
return [];
483+
}
484+
485+
// For access rules, handle grouped format.
486+
if ( 'access' === $type ) {
487+
return $this->sanitize_access_rules_grouped( $rules );
481488
}
489+
490+
// For content rules, use flat format.
491+
$sanitized_rules = [];
482492
foreach ( $rules as $rule ) {
483-
$sanitized = $type === 'access' ? $this->sanitize_access_rule( $rule ) : $this->sanitize_content_rule( $rule );
493+
$sanitized = $this->sanitize_content_rule( $rule );
484494
if ( ! is_wp_error( $sanitized ) ) {
485495
$sanitized_rules[] = $sanitized;
486496
}
487497
}
488498
return $sanitized_rules;
489499
}
490500

501+
/**
502+
* Sanitize access rules in grouped format.
503+
*
504+
* Accepts both flat format [ rule1, rule2 ] and grouped format [ [ rule1, rule2 ], [ rule3 ] ].
505+
* Always returns grouped format [ [ rule1, rule2 ], [ rule3 ] ].
506+
*
507+
* @param array $rules The access rules.
508+
*
509+
* @return array The sanitized access rules in grouped format.
510+
*/
511+
public function sanitize_access_rules_grouped( $rules ) {
512+
if ( empty( $rules ) ) {
513+
return [];
514+
}
515+
516+
// Normalize rules (flat or grouped) to a consistent grouped format.
517+
$rules = Access_Rules::normalize_rules( $rules );
518+
519+
// Sanitize each group.
520+
$sanitized_groups = [];
521+
foreach ( $rules as $group ) {
522+
$sanitized_group = $this->sanitize_access_rules_group( $group );
523+
if ( ! empty( $sanitized_group ) ) {
524+
$sanitized_groups[] = $sanitized_group;
525+
}
526+
}
527+
528+
return $sanitized_groups;
529+
}
530+
531+
/**
532+
* Sanitize a single group of access rules.
533+
*
534+
* @param array $group The group of access rules.
535+
*
536+
* @return array The sanitized group.
537+
*/
538+
public function sanitize_access_rules_group( $group ) {
539+
if ( ! is_array( $group ) ) {
540+
return [];
541+
}
542+
543+
$sanitized_group = [];
544+
foreach ( $group as $rule ) {
545+
$sanitized = $this->sanitize_access_rule( $rule );
546+
if ( ! is_wp_error( $sanitized ) ) {
547+
$sanitized_group[] = $sanitized;
548+
}
549+
}
550+
return $sanitized_group;
551+
}
552+
491553
/**
492554
* Sanitize access rule.
493555
*

src/wizards/audience/views/content-gates/custom-access.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ interface CustomAccessProps {
2020
}
2121

2222
export default function CustomAccess( { gateId, customAccess, onChange, cardProps = {} }: CustomAccessProps ) {
23+
// Get the first group of rules (UI currently only supports a single group).
24+
const currentRules = customAccess.access_rules[ 0 ] || [];
25+
2326
const handleChange = useCallback(
2427
( value: Partial< CustomAccess > ) => {
2528
onChange( {
@@ -31,6 +34,16 @@ export default function CustomAccess( { gateId, customAccess, onChange, cardProp
3134
},
3235
[ customAccess, onChange ]
3336
);
37+
38+
const handleRulesChange = useCallback(
39+
( rules: GateAccessRule[] ) => {
40+
// Wrap rules in a single group to maintain grouped format.
41+
// If no rules, set empty array to avoid [ [] ] which would pass readiness checks.
42+
handleChange( { access_rules: rules.length ? [ rules ] : [] } );
43+
},
44+
[ handleChange ]
45+
);
46+
3447
return (
3548
<ActionCard
3649
title={ __( 'Paid Access', 'newspack-plugin' ) }
@@ -43,10 +56,7 @@ export default function CustomAccess( { gateId, customAccess, onChange, cardProp
4356
>
4457
{ customAccess.active && (
4558
<Card noBorder>
46-
<AccessRules
47-
rules={ customAccess.access_rules }
48-
onChange={ ( rules: GateAccessRule[] ) => handleChange( { access_rules: rules } ) }
49-
/>
59+
<AccessRules rules={ currentRules } onChange={ handleRulesChange } />
5060
<hr />
5161
<Metering metering={ customAccess.metering } onChange={ ( metering: Metering ) => handleChange( { metering } ) } />
5262
</Card>

src/wizards/audience/views/content-gates/types/index.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,13 @@ type Registration = {
8080
gate_layout_id: number;
8181
};
8282

83+
type GateAccessRuleGroup = GateAccessRule[];
84+
8385
type CustomAccess = {
8486
active: boolean;
8587
metering: Metering;
8688
gate_layout_id: number;
87-
access_rules: GateAccessRule[];
89+
access_rules: GateAccessRuleGroup[];
8890
};
8991

9092
type ContentGiftingConfig = {

0 commit comments

Comments
 (0)