MR-5: Metered Paywall#15
Conversation
📝 WalkthroughSummary by CodeRabbit
WalkthroughAdds configurable metering rules, sampled-view tracking, admin editing surfaces, a metering countdown block, and per-post exemption handling across front-end and admin paths. ChangesMetering Feature Implementation
Possibly Related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/sanitizer.php`:
- Around line 30-31: The sanitizer currently coerces missing limits to 0 by
using $input['anonymous_limit'] ?? 0 and $input['registered_limit'] ?? 0 which
can prematurely trip meters; update the logic in the sanitizer to fall back to
the provided $defaults (e.g. $defaults['anonymous_limit'] and
$defaults['registered_limit']) before applying absint and min, so compute
$anonymous_limit = absint($input['anonymous_limit'] ??
$defaults['anonymous_limit']) and $registered_limit =
absint($input['registered_limit'] ?? $defaults['registered_limit']) and then set
$clean['anonymous_limit'] = min($anonymous_limit,
Memberful_Metering_Storage::MAX_VIEWS) and $clean['registered_limit'] =
min($registered_limit, Memberful_Metering_Storage::MAX_VIEWS).
In `@wordpress/wp-content/plugins/memberful-wp/stylesheets/admin.css`:
- Around line 170-172: The section comment
"/*--------------------------------------------------------- Metering
------------------------------------------------------------ */" violates
Stylelint's comment-whitespace-inside; update the comment in admin.css (the
Metering section header) to include a space after the opening /* (e.g. "/*
---------------------------------------------------------") so there is
whitespace inside the comment delimiters and the linter rule passes.
In `@wordpress/wp-content/plugins/memberful-wp/views/option_tabs.php`:
- Around line 18-22: The translated tab title for the 'metering' tab is missing
the plugin text domain; update the array entry with id 'metering' so the 'title'
uses the plugin domain (use __('Metering', 'memberful')) instead of __(
'Metering' ) to ensure proper translation loading in Memberful's locale
files—modify the 'title' value in the array where id => 'metering' and leave the
rest unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: TheCodeCompany/coderabbit/.coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 13cf76e1-dd64-4067-8296-8dbb280c28c5
📒 Files selected for processing (23)
wordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/block.jsonwordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/edit.jswordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/index.jswordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/render.phpwordpress/wp-content/plugins/memberful-wp/js/src/editor-scripts.jswordpress/wp-content/plugins/memberful-wp/js/src/metering-admin.jswordpress/wp-content/plugins/memberful-wp/memberful-wp.phpwordpress/wp-content/plugins/memberful-wp/src/admin.phpwordpress/wp-content/plugins/memberful-wp/src/block-editor.phpwordpress/wp-content/plugins/memberful-wp/src/content_filter.phpwordpress/wp-content/plugins/memberful-wp/src/metering.phpwordpress/wp-content/plugins/memberful-wp/src/metering/access.phpwordpress/wp-content/plugins/memberful-wp/src/metering/config.phpwordpress/wp-content/plugins/memberful-wp/src/metering/metabox.phpwordpress/wp-content/plugins/memberful-wp/src/metering/sanitizer.phpwordpress/wp-content/plugins/memberful-wp/src/metering/storage.phpwordpress/wp-content/plugins/memberful-wp/src/options.phpwordpress/wp-content/plugins/memberful-wp/src/urls.phpwordpress/wp-content/plugins/memberful-wp/stylesheets/admin.csswordpress/wp-content/plugins/memberful-wp/views/metering/metabox.phpwordpress/wp-content/plugins/memberful-wp/views/metering/settings.phpwordpress/wp-content/plugins/memberful-wp/views/option_tabs.phpwordpress/wp-content/plugins/memberful-wp/webpack.config.js
|
|
||
| if ( Memberful_Metering_Access::DECISION_ALLOW_SAMPLE === $metering_decision ) { | ||
| return memberful_wp_strip_paywall_divider_marker( $content ); | ||
| } |
There was a problem hiding this comment.
Metering sample bypasses member ACL
High Severity
When apply_to_protected_posts is enabled, a visitor within their free allowance can receive DECISION_ALLOW_SAMPLE on a members-only post. memberful_wp_protect_content then returns the full post without calling memberful_can_user_access_post, so unpaid users can read restricted content while the meter only decrements their allowance.
Triggered by team rule: Code Review Guidelines
Reviewed by Cursor Bugbot for commit 8bc1dda. Configure here.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
wordpress/wp-content/plugins/memberful-wp/src/metering/access.php (1)
305-309: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAvoid repeated term queries for taxonomy conditions.
condition_matches()can callterm_slugs_for_post()multiple times per request for the same post/taxonomy, causing redundantwp_get_post_terms()lookups.private static function term_slugs_for_post( int $post_id, string $taxonomy ): array { static $cache = array(); $cache_key = $post_id . ':' . $taxonomy; if ( isset( $cache[ $cache_key ] ) ) { return $cache[ $cache_key ]; } $terms = wp_get_post_terms( $post_id, $taxonomy, array( 'fields' => 'all' ) ); if ( is_wp_error( $terms ) || empty( $terms ) ) { $cache[ $cache_key ] = array(); return $cache[ $cache_key ]; } $result = array(); foreach ( $terms as $term ) { $result[] = strtolower( $term->slug ); $result[] = strtolower( $term->name ); } $cache[ $cache_key ] = array_values( array_unique( $result ) ); return $cache[ $cache_key ]; }Also applies to: 346-359
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php` around lines 305 - 309, The term_slugs_for_post() method performs redundant wp_get_post_terms() lookups when called multiple times for the same post and taxonomy combination within a single request. Implement a static cache within the term_slugs_for_post() method using a cache key combining post_id and taxonomy (e.g., "post_id:taxonomy"). At the start of the method, check if the result already exists in this static cache and return it immediately if found. If not cached, proceed with the wp_get_post_terms() call, then store the processed result (including both lowercase slugs and names) in the static cache before returning it. This eliminates redundant database queries for the same post/taxonomy combinations across multiple condition evaluations in a single request.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php`:
- Around line 305-309: The term_slugs_for_post() method performs redundant
wp_get_post_terms() lookups when called multiple times for the same post and
taxonomy combination within a single request. Implement a static cache within
the term_slugs_for_post() method using a cache key combining post_id and
taxonomy (e.g., "post_id:taxonomy"). At the start of the method, check if the
result already exists in this static cache and return it immediately if found.
If not cached, proceed with the wp_get_post_terms() call, then store the
processed result (including both lowercase slugs and names) in the static cache
before returning it. This eliminates redundant database queries for the same
post/taxonomy combinations across multiple condition evaluations in a single
request.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: TheCodeCompany/coderabbit/.coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 73b27091-dc92-486f-a075-97f4e09e0940
📒 Files selected for processing (11)
wordpress/wp-content/plugins/memberful-wp/js/src/metering-admin.jswordpress/wp-content/plugins/memberful-wp/src/admin.phpwordpress/wp-content/plugins/memberful-wp/src/metering/access.phpwordpress/wp-content/plugins/memberful-wp/src/metering/config.phpwordpress/wp-content/plugins/memberful-wp/src/metering/sanitizer.phpwordpress/wp-content/plugins/memberful-wp/stylesheets/admin.csswordpress/wp-content/plugins/memberful-wp/views/metering/chip.phpwordpress/wp-content/plugins/memberful-wp/views/metering/condition.phpwordpress/wp-content/plugins/memberful-wp/views/metering/group.phpwordpress/wp-content/plugins/memberful-wp/views/metering/icon-remove.phpwordpress/wp-content/plugins/memberful-wp/views/metering/settings.php
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit dd979b9. Configure here.
| if ( $user_id && memberful_can_user_access_post( $user_id, $post->ID ) ) { | ||
| self::cache( $post->ID, self::DECISION_IGNORE ); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Registered-only posts skip metering
Medium Severity
Using anonymous ACL failure to mark a post as protected, then skipping metering whenever the logged-in visitor already has access, stops view counting for matching “any registered user” (and similar ACL) posts. Logged-in free members no longer consume their configured registered limit on that content, unlike the prior acl_gated check tied to the current user.
Reviewed by Cursor Bugbot for commit dd979b9. Configure here.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php (1)
24-25: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winUse the scope-specific fallback match.
$matchfalls back to'all'for both scopes, but newexclude_rulesgroups default to'any'later in this file. If saved data is missing or carries an invalidmatch, reopening and saving the form will silently tighten exception groups and meter posts that should stay excluded.$conditions = isset( $group['conditions'] ) && is_array( $group['conditions'] ) ? $group['conditions'] : array(); $default_match = 'exclude_rules' === $scope ? 'any' : 'all'; $match = isset( $group['match'] ) && in_array( $group['match'], Memberful_Metering_Config::MATCH_TYPES, true ) ? $group['match'] : $default_match;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php` around lines 24 - 25, The scope-specific default for the group match is not being preserved in the settings form, so invalid or missing values always fall back to "all". Update the logic in settings.php where $conditions and $match are initialized to use a scope-aware fallback based on $scope (keeping exclude_rules on "any" and other scopes on "all"), and make sure the later form-saving/rendering paths use that same default behavior so reopening and saving does not tighten exclusion groups.wordpress/wp-content/plugins/memberful-wp/src/metering/access.php (1)
325-335: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winMatch URL rules against full permalinks too.
Line 326 strips the permalink to only its path, so an admin-entered full URL like
https://example.com/news/foowill not match/news/foo. That can silently skip metering or exclusion rules configured with copied permalinks.case 'url': $permalink = (string) get_permalink( $post->ID ); $path = wp_parse_url( $permalink, PHP_URL_PATH ); $path = is_string( $path ) ? $path : ''; $matches = false; foreach ( $values as $fragment ) { $fragment = (string) $fragment; if ( '' !== $fragment && ( false !== strpos( $path, $fragment ) || false !== strpos( $permalink, $fragment ) ) ) { $matches = true; break; } } break;As per path instructions, review possible edge cases and unexpected behaviour for PHP/WordPress code.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php` around lines 325 - 335, The URL matching logic in the access metering rule currently only checks the parsed path from get_permalink(), so full URLs entered in admin rules will not match. Update the 'url' case in access.php to compare each fragment against both the permalink path and the full permalink string, using the existing get_permalink(), wp_parse_url(), and strpos() flow, so copied URLs like https://example.com/news/foo are matched correctly.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php`:
- Around line 325-335: The URL matching logic in the access metering rule
currently only checks the parsed path from get_permalink(), so full URLs entered
in admin rules will not match. Update the 'url' case in access.php to compare
each fragment against both the permalink path and the full permalink string,
using the existing get_permalink(), wp_parse_url(), and strpos() flow, so copied
URLs like https://example.com/news/foo are matched correctly.
In `@wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php`:
- Around line 24-25: The scope-specific default for the group match is not being
preserved in the settings form, so invalid or missing values always fall back to
"all". Update the logic in settings.php where $conditions and $match are
initialized to use a scope-aware fallback based on $scope (keeping exclude_rules
on "any" and other scopes on "all"), and make sure the later
form-saving/rendering paths use that same default behavior so reopening and
saving does not tighten exclusion groups.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository: TheCodeCompany/coderabbit/.coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 943b259b-e4f2-44f0-b016-faa5fdf141c8
📒 Files selected for processing (5)
wordpress/wp-content/plugins/memberful-wp/src/metering/access.phpwordpress/wp-content/plugins/memberful-wp/src/metering/config.phpwordpress/wp-content/plugins/memberful-wp/src/metering/storage.phpwordpress/wp-content/plugins/memberful-wp/stylesheets/admin.csswordpress/wp-content/plugins/memberful-wp/views/metering/settings.php


Summary
This PR adds a metered paywall on top of the existing Memberful paywall: a "free article allowance" that lets anonymous visitors and registered free members read a configurable number of matching posts within a rolling period before the paywall is shown.
What's included
{count}template, shown only while the visitor is still being sampled.Test plan
{count}button, and confirm it shows the remaining count while sampling and disappears once the meter trips.Note
High Risk
Changes core content protection and paywall behavior, persists visitor state in cookies/user meta, and disables page caching on metered views—misconfiguration or edge-cache bypass could leak paywalled content or serve wrong counts.
Overview
Adds a metered paywall on top of the existing Memberful paywall: visitors get a rolling free-article allowance before content is gated.
Admin & config: New Metering settings tab (enable toggle, period, anonymous vs registered limits, optional metering of members-only posts). Include/except rule groups (post type, category/tag, URL) are edited via a new
metering-admin.jsUI with token-field search. Config is sanitized and stored inmemberful_metering_config; a per-post Exempt from metering metabox is available when metering is on.Runtime:
Memberful_Metering_Accessruns ontemplate_redirect, matches rules, tracks views in a signed HttpOnly cookie (anonymous) or user meta (logged-in), merges anonymous views on login, and caches per-request decisions (allow_sample/trip_meter). Metered responses set no-cache headers.content_filter.phpnow skips paywall for sampled posts, forces the gate when the meter trips, and only applies metering to the queried singular post so relatedthe_contentpasses don’t consume views.Editor: Registers a Metering Countdown block with a
{count}template, server-rendered only while the visitor is still in the free sample window.Reviewed by Cursor Bugbot for commit dd979b9. Bugbot is set up for automated code reviews on this repo. Configure here.