Skip to content

Fix wp engine issue - #22

Open
av3nger wants to merge 11 commits into
feature/MR-5-metered-paywallfrom
fix/MR-5-wp-engine
Open

Fix wp engine issue#22
av3nger wants to merge 11 commits into
feature/MR-5-metered-paywallfrom
fix/MR-5-wp-engine

Conversation

@av3nger

@av3nger av3nger commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Note

High Risk
Large change to paywall/metering enforcement, anonymous identity, and content release over ajax; mistakes could leak protected content or miscount views under cache and concurrency.

Overview
Anonymous metered pages are now edge-cacheable by serving the same HTML to every logged-out visitor and enforcing limits in the browser plus an uncacheable admin-ajax path, instead of baking per-visitor counts into PHP responses (the WP Engine / full-page cache failure mode).

Storage and enforcement split: anonymous view counts move from a signed cookie payload to a server-side transient ledger keyed by an opaque, HttpOnly subject cookie minted only on ajax (not on cacheable page views). Public metered posts use localStorage for immediate enforcement with background record_public sync; protected samples stay gated in cached HTML until the sample op releases full body HTML from the server ledger. Login-time anonymous cookie merge is removed; anonymous and registered allowances stay independent per the readme.

New surface area: metering.js runtime with prepaint anti-flash CSS, countdown block client hydration, default paywall when marketing is empty, optional paywall free-view counter ({limit}), rate limits / cookieless-release cap on the sample endpoint, and Node tests for the runtime sync behavior. Admin copy now describes cache-safe behavior for anonymous visitors.

Reviewed by Cursor Bugbot for commit 01ab0e3. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds anonymous metering across server, storage, endpoint, frontend, and client paths. Posts receive render modes, anonymous subjects use server-side ledgers, protected samples can be released through AJAX, and paywall counters can display remaining views.

Changes

Anonymous metering runtime

Layer / File(s) Summary
Access classification and subject ledger
src/metering/access.php, src/metering/storage.php
Separates anonymous render classification from enforcement and replaces anonymous view cookies with signed subjects and server-side ledgers.
Protected sample endpoint
src/metering/sample_endpoint.php
Adds the logged-out sample endpoint with validation, origin and rate limiting, cookieless caps, server evaluation, and rendered-body responses.
Release rendering and anonymous wrappers
src/content_filter.php
Adds release-state bypasses, anonymous free/protected wrappers, fallback paywall content, and full metered-body rendering.
Frontend enqueue and client runtime
src/metering/frontend.php, js/src/metering.js, stylesheets/metering.css, webpack.config.js, js/src/blocks/metering-countdown/render.php
Adds cache-safe assets, pre-paint metering state, browser ledger handling, protected sample requests, countdown hydration, and content/paywall visibility changes.
Paywall free-view counter
src/paywall/*, stylesheets/paywall.css, views/paywall/builder-panel.php, js/src/paywall-builder.js
Adds configurable counter settings, preview support, sanitization, rendered meter markup, styling, and builder controls.
Loader, tests, and documentation
src/metering.php, js/metering.test.js, package.json, readme.txt, views/metering/settings.php
Registers metering components, tests client synchronization behavior, adds the JavaScript test command, and documents metering configuration and caching behavior.

Possibly related PRs

Poem

Render modes softly divide,
Ledgers keep the views inside.
Samples cross the AJAX stream,
Counters glow with cached HTML sheen.
Paywalls hide, then reappear.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title is related to the PR, but it is too generic to convey the main change. Rename it to something specific like "Refactor anonymous metering for edge-cacheable paywall rendering".
✅ Passed checks (4 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 98.25% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description matches the cache-safe metering and paywall refactor described in the changeset.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/MR-5-wp-engine

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread wordpress/wp-content/plugins/memberful-wp/js/src/metering.js

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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/js/src/metering.js`:
- Line 68: The metering template hydration only replaces the first {count}
token, which can diverge from the PHP rendering path. Update the logic in
metering.js where node.textContent is assigned so it replaces every occurrence
of {count}, matching the behavior of the server-side str_replace and ensuring
templates with multiple placeholders render consistently.
- Around line 52-54: The `record` helper in `metering.js` is overwriting an
existing `cfg.postId` timestamp every time it runs, which should only happen for
first-time counts. Update `record` so it preserves the existing value when
`views[cfg.postId]` is already present, and only assigns `nowSeconds()` for new
post IDs before calling `persist(views)`. Use the existing `record_and_check()`
flow and `cfg.postId` lookup to keep the original view timestamp intact.

In `@wordpress/wp-content/plugins/memberful-wp/src/content_filter.php`:
- Around line 261-272: Wrap the state mutation in
memberful_wp_render_metered_body() around the apply_filters('the_content', ...)
call with a try/finally so the original $GLOBALS['post'], wp_reset_postdata(),
and memberful_metering_releasing_post_id(0) are always restored even if a
shortcode/block/plugin throws. Use the existing
memberful_wp_render_metered_body() and memberful_metering_releasing_post_id()
symbols to keep the release state and global post isolated for later rendering
in the same request.
🪄 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: a7ab6923-e0da-4855-b4b6-28f50157a9b6

📥 Commits

Reviewing files that changed from the base of the PR and between dd979b9 and 22cb6aa.

📒 Files selected for processing (9)
  • wordpress/wp-content/plugins/memberful-wp/js/src/blocks/metering-countdown/render.php
  • wordpress/wp-content/plugins/memberful-wp/js/src/metering.js
  • wordpress/wp-content/plugins/memberful-wp/src/content_filter.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/access.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/frontend.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/sample_endpoint.php
  • wordpress/wp-content/plugins/memberful-wp/stylesheets/metering.css
  • wordpress/wp-content/plugins/memberful-wp/webpack.config.js

Comment thread wordpress/wp-content/plugins/memberful-wp/js/src/metering.js Outdated
Comment thread wordpress/wp-content/plugins/memberful-wp/js/src/metering.js Outdated
Comment thread wordpress/wp-content/plugins/memberful-wp/src/content_filter.php Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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/js/src/metering.js (1)

79-86: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Hydrate every countdown block, not just the first.

querySelector() updates only one [data-memberful-countdown], so additional countdown blocks on the page stay stale after client hydration.

  const hydrateCountdown = (remaining) => {
    const nodes = document.querySelectorAll('[data-memberful-countdown]');
    if (!nodes.length) {
      return;
    }

    const count = String(Math.max(0, remaining));

    nodes.forEach((node) => {
      const template = node.getAttribute('data-memberful-template') || '';
      node.textContent = template.replace(/\{count\}/g, count);
      node.hidden = false;
    });
  };

As per path instructions, “Review for possible edge cases and unexpected behaviour.”

🤖 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/js/src/metering.js` around lines 79
- 86, The hydrateCountdown helper currently uses document.querySelector(), so
only the first [data-memberful-countdown] element is updated during hydration.
Update hydrateCountdown in metering.js to target all matching nodes, iterate
over each one, and apply the same template replacement and hidden=false behavior
so every countdown block stays in sync after client-side hydration.

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.

Inline comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/frontend.php`:
- Around line 41-45: The inline runtime config in memberful_metering frontend
setup should fail closed when JSON encoding fails instead of outputting a false
value. Update the logic around memberful_metering_runtime_config() and
wp_json_encode() so the encoded result is stored first, checked for false, and
the wp_add_inline_script() call is skipped on failure, matching the guarded
behavior used in memberful_metering_prepaint().

---

Outside diff comments:
In `@wordpress/wp-content/plugins/memberful-wp/js/src/metering.js`:
- Around line 79-86: The hydrateCountdown helper currently uses
document.querySelector(), so only the first [data-memberful-countdown] element
is updated during hydration. Update hydrateCountdown in metering.js to target
all matching nodes, iterate over each one, and apply the same template
replacement and hidden=false behavior so every countdown block stays in sync
after client-side hydration.
🪄 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: 51799867-0a03-41d9-93df-8540eae16491

📥 Commits

Reviewing files that changed from the base of the PR and between 22cb6aa and 99b02a2.

📒 Files selected for processing (5)
  • wordpress/wp-content/plugins/memberful-wp/js/src/metering.js
  • wordpress/wp-content/plugins/memberful-wp/src/content_filter.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/access.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/frontend.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/sample_endpoint.php

Comment thread wordpress/wp-content/plugins/memberful-wp/src/metering/frontend.php

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

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)

152-195: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Serialize anonymous ledger updates before releasing protected content.

Concurrent requests for one subject can all read the same under-limit ledger, each return allowed, and overwrite one another. Since subjects with an active ledger bypass the cookieless cap, repeated bursts can exceed anonymous_limit.

Use a per-subject lock or atomic compare-and-set around the complete read/prune/check/write operation, failing closed when synchronization cannot be acquired. This requires coordinated storage changes, so a local suggestion is not safe.

As per path instructions, review possible edge cases and unexpected behaviour.

🤖 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 152 - 195, Serialize the complete anonymous flow in
Memberful_Metering_Access::record_and_check for each subject: acquire a
per-subject lock or atomic compare-and-set before read/prune/check/write, hold
it through the decision, and release it on every return path. Fail closed with
allowed=false when synchronization cannot be acquired, while preserving the
existing user-based path and ledger semantics; update Memberful_Metering_Storage
with the required coordinated locking/CAS primitives and handle stale-lock,
cleanup, and failure cases safely.

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.

Inline comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/sample_endpoint.php`:
- Around line 176-185: Update is_rate_limited so it increments and checks the
per-IP counter first, immediately returning true when RATE_PER_IP is exceeded;
only requests within the per-IP limit should increment and evaluate the global
counter against RATE_GLOBAL.
- Around line 160-167: Update the origin comparison around the source/site URL
parsing so it validates and compares scheme, host, and effective port rather
than hosts alone. Restrict schemes to HTTP/HTTPS, reject missing or invalid
origins, apply default ports based on each scheme, and return true whenever any
origin component differs; preserve the existing same-origin false result.

In `@wordpress/wp-content/plugins/memberful-wp/src/metering/storage.php`:
- Around line 64-87: Update get_or_create_subject so it always recalculates the
expiry, signs the existing or newly generated subject with that expiry, sends
the refreshed cookie, and updates $_COOKIE[ self::COOKIE_NAME ]. Preserve
subject generation only when read_subject returns null, and review behavior for
invalid period_days and existing cookie values.
- Around line 204-220: Update Metering storage’s incr_counter method to enforce
fixed-window fallback counters by clamping $ttl to at least 1 and appending the
current floor(time() / $ttl) bucket to $key before cache or transient
operations. Preserve the existing increment and expiry behavior while ensuring
continuous traffic creates a new counter at each window boundary instead of
extending one indefinitely.

---

Outside diff comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php`:
- Around line 152-195: Serialize the complete anonymous flow in
Memberful_Metering_Access::record_and_check for each subject: acquire a
per-subject lock or atomic compare-and-set before read/prune/check/write, hold
it through the decision, and release it on every return path. Fail closed with
allowed=false when synchronization cannot be acquired, while preserving the
existing user-based path and ledger semantics; update Memberful_Metering_Storage
with the required coordinated locking/CAS primitives and handle stale-lock,
cleanup, and failure cases safely.
🪄 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: 63f570eb-162d-4a75-b859-f755ad108649

📥 Commits

Reviewing files that changed from the base of the PR and between 99b02a2 and 7521337.

📒 Files selected for processing (5)
  • wordpress/wp-content/plugins/memberful-wp/js/src/metering.js
  • wordpress/wp-content/plugins/memberful-wp/src/metering/access.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/frontend.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/sample_endpoint.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/storage.php
💤 Files with no reviewable changes (1)
  • wordpress/wp-content/plugins/memberful-wp/js/src/metering.js

Comment on lines +160 to +167
$source_host = wp_parse_url( $source, PHP_URL_HOST );
$site_host = wp_parse_url( home_url(), PHP_URL_HOST );

if ( ! is_string( $source_host ) || ! is_string( $site_host ) ) {
return true;
}

return strtolower( $source_host ) !== strtolower( $site_host );

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Compare complete origins, not only hosts.

Different schemes or ports are cross-origin but currently pass this check.

Suggested change
$source_host = wp_parse_url( $source, PHP_URL_HOST );
$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
if ( ! is_string( $source_host ) || ! is_string( $site_host ) ) {
return true;
}
return strtolower( $source_host ) !== strtolower( $site_host );
$source_url = wp_parse_url( $source );
$site_url = wp_parse_url( home_url() );
if ( ! is_array( $source_url ) || ! is_array( $site_url ) ) {
return true;
}
$source_scheme = isset( $source_url['scheme'] ) ? strtolower( $source_url['scheme'] ) : '';
$site_scheme = isset( $site_url['scheme'] ) ? strtolower( $site_url['scheme'] ) : '';
$source_host = isset( $source_url['host'] ) ? strtolower( $source_url['host'] ) : '';
$site_host = isset( $site_url['host'] ) ? strtolower( $site_url['host'] ) : '';
if (
! in_array( $source_scheme, array( 'http', 'https' ), true )
|| ! in_array( $site_scheme, array( 'http', 'https' ), true )
|| '' === $source_host
|| '' === $site_host
) {
return true;
}
$source_port = isset( $source_url['port'] ) ? (int) $source_url['port'] : ( 'https' === $source_scheme ? 443 : 80 );
$site_port = isset( $site_url['port'] ) ? (int) $site_url['port'] : ( 'https' === $site_scheme ? 443 : 80 );
return $source_scheme !== $site_scheme
|| $source_host !== $site_host
|| $source_port !== $site_port;

As per path instructions, review possible edge cases and unexpected behaviour.

🤖 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/sample_endpoint.php`
around lines 160 - 167, Update the origin comparison around the source/site URL
parsing so it validates and compares scheme, host, and effective port rather
than hosts alone. Restrict schemes to HTTP/HTTPS, reject missing or invalid
origins, apply default ports based on each scheme, and return true whenever any
origin component differs; preserve the existing same-origin false result.

Source: Path instructions

Comment thread wordpress/wp-content/plugins/memberful-wp/src/metering/sample_endpoint.php Outdated
Comment on lines +64 to +87
/**
* Return the subject id for this request, minting and setting the cookie when absent.
*
* MUST be called only from an uncached context (admin-ajax, login) — never while rendering a cacheable page, or the
* Set-Cookie would either be dropped by the cache or baked into shared HTML.
*
* @param int $period_days Rolling-window length; also drives the cookie's expiry.
*
* @return string
*/
public static function get_or_create_subject( int $period_days ): string {
$subject = self::read_subject();

if ( null === $subject ) {
$subject = bin2hex( random_bytes( self::SUBJECT_BYTES ) );
$expires = time() + ( max( 1, $period_days ) * DAY_IN_SECONDS );
$signed = self::sign( $subject, $expires );

self::send_cookie( $signed, $expires );
$_COOKIE[ self::COOKIE_NAME ] = $signed;
}

return self::apply_subject_filter( $subject );
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Refresh the subject expiry when recording later views.

The cookie expires period_days after its first creation, even when newer ledger entries remain active. A subject created on day 1 and used on day 29 resets on day 31, bypassing the rolling window.

Suggested change
/**
* Return the subject id for this request, minting and setting the cookie when absent.
*
* MUST be called only from an uncached context (admin-ajax, login) — never while rendering a cacheable page, or the
* Set-Cookie would either be dropped by the cache or baked into shared HTML.
*
* @param int $period_days Rolling-window length; also drives the cookie's expiry.
*
* @return string
*/
public static function get_or_create_subject( int $period_days ): string {
$subject = self::read_subject();
if ( null === $subject ) {
$subject = bin2hex( random_bytes( self::SUBJECT_BYTES ) );
$expires = time() + ( max( 1, $period_days ) * DAY_IN_SECONDS );
$signed = self::sign( $subject, $expires );
self::send_cookie( $signed, $expires );
$_COOKIE[ self::COOKIE_NAME ] = $signed;
}
return self::apply_subject_filter( $subject );
}
/**
* Return the subject id for this request, minting it when absent and refreshing the signed cookie expiry.
*
* MUST be called only from an uncached context (admin-ajax, login) — never while rendering a cacheable page, or the
* Set-Cookie would either be dropped by the cache or baked into shared HTML.
*
* `@param` int $period_days Rolling-window length; also drives the cookie's expiry.
*
* `@return` string
*/
public static function get_or_create_subject( int $period_days ): string {
$subject = self::read_subject();
if ( null === $subject ) {
$subject = bin2hex( random_bytes( self::SUBJECT_BYTES ) );
}
$expires = time() + ( max( 1, $period_days ) * DAY_IN_SECONDS );
$signed = self::sign( $subject, $expires );
self::send_cookie( $signed, $expires );
$_COOKIE[ self::COOKIE_NAME ] = $signed;
return self::apply_subject_filter( $subject );
}

As per path instructions, review possible edge cases and unexpected behaviour.

🧰 Tools
🪛 PHPMD (2.15.0)

[error] 74-87: get_or_create_subject accesses the super-global variable $_COOKIE. (undefined)

(Superglobals)


[error] 74-87: The method get_or_create_subject is not named in camelCase. (undefined)

(CamelCaseMethodName)


[error] 74-87: The parameter $period_days is not named in camelCase. (undefined)

(CamelCaseParameterName)

🪛 PHPStan (2.2.5)

[warning] 79-79: Constant DAY_IN_SECONDS not found.
Learn more at https://phpstan.org/user-guide/discovering-symbols

(constant.notFound)

🤖 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/storage.php` around
lines 64 - 87, Update get_or_create_subject so it always recalculates the
expiry, signs the existing or newly generated subject with that expiry, sends
the refreshed cookie, and updates $_COOKIE[ self::COOKIE_NAME ]. Preserve
subject generation only when read_subject returns null, and review behavior for
invalid period_days and existing cookie values.

Source: Path instructions

Comment on lines +204 to +220
public static function incr_counter( string $key, int $ttl ): int {
if ( wp_using_ext_object_cache() ) {
wp_cache_add( $key, 0, self::CACHE_GROUP, $ttl );
$new = wp_cache_incr( $key, 1, self::CACHE_GROUP );
if ( is_int( $new ) ) {
return $new;
}
// Object cache present but without a working incr: fall through to the transient counter. A given backend is
// consistent about incr support, so reads and writes stay on one store (read_counter no longer exists to split
// them). This never returns a constant that would disable enforcement.
}

// Transient counter: atomic where transients are backed by a persistent object cache; best-effort (approximate
// under heavy concurrency) on hosts with no object cache, where WordPress exposes no atomic increment primitive.
// Acceptable for a soft meter — the per-subject ledger cap still bounds each individual visitor regardless.
$value = (int) get_transient( $key ) + 1;
set_transient( $key, $value, $ttl );

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Keep fallback counters fixed-window.

Passing $ttl to every set_transient() call refreshes its expiry, so continuous traffic can keep the global endpoint blocked indefinitely. WordPress confirms that updating a transient with an expiration updates its expiry. (developer.wordpress.org)

Bucket the key so old counters become irrelevant at the window boundary:

Suggested change
public static function incr_counter( string $key, int $ttl ): int {
if ( wp_using_ext_object_cache() ) {
wp_cache_add( $key, 0, self::CACHE_GROUP, $ttl );
$new = wp_cache_incr( $key, 1, self::CACHE_GROUP );
if ( is_int( $new ) ) {
return $new;
}
// Object cache present but without a working incr: fall through to the transient counter. A given backend is
// consistent about incr support, so reads and writes stay on one store (read_counter no longer exists to split
// them). This never returns a constant that would disable enforcement.
}
// Transient counter: atomic where transients are backed by a persistent object cache; best-effort (approximate
// under heavy concurrency) on hosts with no object cache, where WordPress exposes no atomic increment primitive.
// Acceptable for a soft meter — the per-subject ledger cap still bounds each individual visitor regardless.
$value = (int) get_transient( $key ) + 1;
set_transient( $key, $value, $ttl );
public static function incr_counter( string $key, int $ttl ): int {
$ttl = max( 1, $ttl );
$key .= '_' . (string) floor( time() / $ttl );
if ( wp_using_ext_object_cache() ) {

As per path instructions, follow WordPress best practices.

🧰 Tools
🪛 PHPMD (2.15.0)

[error] 204-223: The method incr_counter is not named in camelCase. (undefined)

(CamelCaseMethodName)

🤖 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/storage.php` around
lines 204 - 220, Update Metering storage’s incr_counter method to enforce
fixed-window fallback counters by clamping $ttl to at least 1 and appending the
current floor(time() / $ttl) bucket to $key before cache or transient
operations. Preserve the existing increment and expiry behavior while ensuring
continuous traffic creates a new counter at each window boundary instead of
extending one indefinitely.

Source: Path instructions

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@av3nger this seems to be a legit issue. Refreshing the TTL on every increment, and over-limit IPs still bump the global counter. Once the global sample bucket passes 600/min it never expires while traffic continues, so all anonymous visitors may get 429 and no protected content is released until the endpoint sees a full minute of no requests.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ 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 c5c1c76. Configure here.

&& Memberful_Metering_Access::RENDER_FREE_METER === Memberful_Metering_Access::current_anon_mode( $post->ID )
) {
return $content;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Divider teaser skipped by ID check

Medium Severity

The if condition preserving the paywall divider marker for metered content fails due to a strict equality (===) comparison between $post->ID (string) and get_queried_object_id() (int). This type mismatch incorrectly strips the divider, preventing the above-divider teaser from showing in the metered paywall.

Fix in Cursor Fix in Web

Triggered by team rule: Code Review Guidelines

Reviewed by Cursor Bugbot for commit c5c1c76. Configure here.

array(
'released' => false,
'remaining' => 0,
'synced' => $synced,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cap exemption needs fragile marker

Medium Severity

Cookieless-cap exemption now depends on a separate protected_release transient plus a non-empty ledger, instead of the ledger alone. On hosts that evict object-cache transients under pressure (including WP Engine), the marker can disappear while the ledger remains, so returning visitors are treated as cookieless again and burn the hourly cap—even after a prior successful protected release.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by team rule: Code Review Guidelines

Reviewed by Cursor Bugbot for commit c5c1c76. Configure here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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/src/metering/sample_endpoint.php (1)

232-243: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A single over-limit IP can still exhaust the per-op global bucket.

Both counters are incremented unconditionally before checking either threshold. Once an IP exceeds RATE_PER_IP, its continued requests still bump $global_key, letting it drive the op's global counter past RATE_GLOBAL and 429 every other visitor for that operation.

🛠️ Suggested fix (short-circuit before the global increment)
  private static function is_rate_limited( string $op ): bool {
    $bucket     = ( 'record_public' === $op ) ? 'public' : 'sample';
    $ip_key     = 'mbf_mtr_rl_' . $bucket . '_' . hash( 'sha256', self::client_ip() . wp_salt( 'auth' ) );
    $global_key = 'mbf_mtr_rl_global_' . $bucket;

-    // Increment first, then compare the returned value: concurrent requests get distinct atomic counts, so they cannot
-    // all observe an under-limit value and slip through. Over-limit requests still increment, which keeps them blocked.
-    $ip_hits     = Memberful_Metering_Storage::incr_counter( $ip_key, MINUTE_IN_SECONDS );
-    $global_hits = Memberful_Metering_Storage::incr_counter( $global_key, MINUTE_IN_SECONDS );
-
-    return ( $ip_hits > self::RATE_PER_IP || $global_hits > self::RATE_GLOBAL );
+    $ip_hits = Memberful_Metering_Storage::incr_counter( $ip_key, MINUTE_IN_SECONDS );
+    if ( $ip_hits > self::RATE_PER_IP ) {
+      return true;
+    }
+
+    $global_hits = Memberful_Metering_Storage::incr_counter( $global_key, MINUTE_IN_SECONDS );
+
+    return $global_hits > self::RATE_GLOBAL;
  }

As per path instructions, review possible edge cases and unexpected behaviour.

🤖 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/sample_endpoint.php`
around lines 232 - 243, Update is_rate_limited so it checks the per-IP counter
immediately after incrementing it and returns true when RATE_PER_IP is exceeded,
before incrementing the operation’s global counter. Only requests still under
the per-IP limit should increment and evaluate global_hits, preserving rate
limiting for both thresholds without allowing one IP to consume the global
bucket.

Source: Path instructions

wordpress/wp-content/plugins/memberful-wp/src/metering/access.php (1)

355-366: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated post-eligibility guard across three methods.

The publish + is_post_publicly_viewable + !post_password_required check is repeated verbatim in is_releasable_sample(), evaluate_sample(), and the loop inside record_public_views(). A future tightening (e.g. adding a sticky/expiry check) applied to one call site but missed in another would silently create an inconsistent access decision.

♻️ Suggested extraction
+  /**
+   * Whether a post is currently publicly readable content (published, publicly viewable, not password protected).
+   *
+   * `@param` WP_Post|null $post Post to check.
+   *
+   * `@return` bool
+   */
+  private static function is_publicly_readable( ?WP_Post $post ): bool {
+    return ( $post instanceof WP_Post )
+      && 'publish' === $post->post_status
+      && is_post_publicly_viewable( $post )
+      && ! post_password_required( $post );
+  }

Then replace each ! ( $post instanceof WP_Post ) || 'publish' !== $post->post_status / ! is_post_publicly_viewable( $post ) || post_password_required( $post ) pair with ! self::is_publicly_readable( $post ).

Also applies to: 424-437, 299-309

🤖 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 355 - 366, Extract the shared
published/publicly-viewable/not-password-protected eligibility logic into an
`is_publicly_readable()` helper, then update `is_releasable_sample()`,
`evaluate_sample()`, and the `record_public_views()` loop to use `!
self::is_publicly_readable( $post )` while preserving their existing behavior.
♻️ Duplicate comments (2)
wordpress/wp-content/plugins/memberful-wp/src/metering/sample_endpoint.php (1)

202-222: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Compare full origin (scheme + host + port), not host alone.

A source URL with a different scheme or port than home_url() still passes this check, since only PHP_URL_HOST is compared.

🔒 Suggested fix
  private static function is_cross_origin(): bool {
    $source = '';
    if ( ! empty( $_SERVER['HTTP_ORIGIN'] ) ) {
      $source = (string) wp_unslash( $_SERVER['HTTP_ORIGIN'] );
    } elseif ( ! empty( $_SERVER['HTTP_REFERER'] ) ) {
      $source = (string) wp_unslash( $_SERVER['HTTP_REFERER'] );
    }

    if ( '' === $source ) {
      return false;
    }

-    $source_host = wp_parse_url( $source, PHP_URL_HOST );
-    $site_host   = wp_parse_url( home_url(), PHP_URL_HOST );
-
-    if ( ! is_string( $source_host ) || ! is_string( $site_host ) ) {
-      return true;
-    }
-
-    return strtolower( $source_host ) !== strtolower( $site_host );
+    $source_url = wp_parse_url( $source );
+    $site_url   = wp_parse_url( home_url() );
+
+    if ( ! is_array( $source_url ) || ! is_array( $site_url ) ) {
+      return true;
+    }
+
+    $source_scheme = isset( $source_url['scheme'] ) ? strtolower( $source_url['scheme'] ) : '';
+    $site_scheme   = isset( $site_url['scheme'] ) ? strtolower( $site_url['scheme'] ) : '';
+    $source_host   = isset( $source_url['host'] ) ? strtolower( $source_url['host'] ) : '';
+    $site_host     = isset( $site_url['host'] ) ? strtolower( $site_url['host'] ) : '';
+
+    if ( '' === $source_host || '' === $site_host ) {
+      return true;
+    }
+
+    $source_port = isset( $source_url['port'] ) ? (int) $source_url['port'] : ( 'https' === $source_scheme ? 443 : 80 );
+    $site_port   = isset( $site_url['port'] ) ? (int) $site_url['port'] : ( 'https' === $site_scheme ? 443 : 80 );
+
+    return $source_scheme !== $site_scheme
+      || $source_host !== $site_host
+      || $source_port !== $site_port;
  }

As per path instructions, review possible edge cases and unexpected behaviour.

🤖 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/sample_endpoint.php`
around lines 202 - 222, Update is_cross_origin() to compare the complete parsed
origin of the source URL and home_url(), including scheme, host, and port,
rather than comparing only PHP_URL_HOST. Normalize comparisons consistently,
preserve the existing true result when either URL cannot be parsed, and ensure
differing schemes or ports are treated as cross-origin.

Source: Path instructions

wordpress/wp-content/plugins/memberful-wp/src/metering/storage.php (1)

253-272: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fixed-window counters still extend on every hit — same issue as before.

set_transient( $key, $value, $ttl ) resets the transient's expiry on every call. Under continuous traffic against the same IP/global key, the window never rolls over — a blocked caller stays blocked indefinitely instead of resetting each minute/hour, and this backs both is_rate_limited() and cookieless_over_cap() in sample_endpoint.php.

🛠️ Suggested fix (fixed time-bucket key)
   public static function incr_counter( string $key, int $ttl ): int {
+    $ttl  = max( 1, $ttl );
+    $key .= '_' . (string) floor( time() / $ttl );
+
     if ( wp_using_ext_object_cache() ) {
       wp_cache_add( $key, 0, self::CACHE_GROUP, $ttl );
       $new = wp_cache_incr( $key, 1, self::CACHE_GROUP );

As per path instructions, review possible edge cases and unexpected behaviour.

🤖 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/storage.php` around
lines 253 - 272, Update Metering storage::incr_counter so transient counters use
a fixed time-bucket key or otherwise preserve the original window expiry instead
of resetting the TTL on every increment. Ensure repeated hits to the same
counter roll over at the configured fixed window, while retaining the existing
object-cache increment path and return-value behavior used by is_rate_limited()
and cookieless_over_cap().

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.

Inline comments:
In `@wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php`:
- Around line 69-75: Update the metering settings output to use late escaping:
replace the current printf/wp_kses_post pattern with sprintf applied to the
translated message and URL, then pass the complete result to wp_kses_post before
echoing it. Preserve the existing translation string, translator comment, and
memberful_wp_plugin_global_marketing_url() value.

---

Outside diff comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/access.php`:
- Around line 355-366: Extract the shared
published/publicly-viewable/not-password-protected eligibility logic into an
`is_publicly_readable()` helper, then update `is_releasable_sample()`,
`evaluate_sample()`, and the `record_public_views()` loop to use `!
self::is_publicly_readable( $post )` while preserving their existing behavior.

In `@wordpress/wp-content/plugins/memberful-wp/src/metering/sample_endpoint.php`:
- Around line 232-243: Update is_rate_limited so it checks the per-IP counter
immediately after incrementing it and returns true when RATE_PER_IP is exceeded,
before incrementing the operation’s global counter. Only requests still under
the per-IP limit should increment and evaluate global_hits, preserving rate
limiting for both thresholds without allowing one IP to consume the global
bucket.

---

Duplicate comments:
In `@wordpress/wp-content/plugins/memberful-wp/src/metering/sample_endpoint.php`:
- Around line 202-222: Update is_cross_origin() to compare the complete parsed
origin of the source URL and home_url(), including scheme, host, and port,
rather than comparing only PHP_URL_HOST. Normalize comparisons consistently,
preserve the existing true result when either URL cannot be parsed, and ensure
differing schemes or ports are treated as cross-origin.

In `@wordpress/wp-content/plugins/memberful-wp/src/metering/storage.php`:
- Around line 253-272: Update Metering storage::incr_counter so transient
counters use a fixed time-bucket key or otherwise preserve the original window
expiry instead of resetting the TTL on every increment. Ensure repeated hits to
the same counter roll over at the configured fixed window, while retaining the
existing object-cache increment path and return-value behavior used by
is_rate_limited() and cookieless_over_cap().
🪄 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: bd979c77-e5f8-4d70-ae21-6c07475a95d1

📥 Commits

Reviewing files that changed from the base of the PR and between 7521337 and c5c1c76.

📒 Files selected for processing (17)
  • wordpress/wp-content/plugins/memberful-wp/js/metering.test.js
  • wordpress/wp-content/plugins/memberful-wp/js/src/metering.js
  • wordpress/wp-content/plugins/memberful-wp/js/src/paywall-builder.js
  • wordpress/wp-content/plugins/memberful-wp/package.json
  • wordpress/wp-content/plugins/memberful-wp/readme.txt
  • wordpress/wp-content/plugins/memberful-wp/src/content_filter.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/access.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/sample_endpoint.php
  • wordpress/wp-content/plugins/memberful-wp/src/metering/storage.php
  • wordpress/wp-content/plugins/memberful-wp/src/paywall/config.php
  • wordpress/wp-content/plugins/memberful-wp/src/paywall/preview.php
  • wordpress/wp-content/plugins/memberful-wp/src/paywall/renderer.php
  • wordpress/wp-content/plugins/memberful-wp/src/paywall/sanitizer.php
  • wordpress/wp-content/plugins/memberful-wp/stylesheets/paywall.css
  • wordpress/wp-content/plugins/memberful-wp/views/metering/settings.php
  • wordpress/wp-content/plugins/memberful-wp/views/paywall/builder-panel.php
💤 Files with no reviewable changes (1)
  • wordpress/wp-content/plugins/memberful-wp/src/metering.php

Comment on lines +69 to +75
<?php
printf(
/* translators: %s: URL of the paywall / global marketing settings screen. */
wp_kses_post( __( 'Metering is on, but no paywall content is configured, so metered posts show a generic default message. <a href="%s">Set up your paywall</a> to customise what non-members see.', 'memberful' ) ),
esc_url( memberful_wp_plugin_global_marketing_url() )
);
?>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Apply late escaping for WordPress VIP compliance.

WordPress VIP coding standards highly recommend "late escaping" when combining translation strings with variables. By escaping the final output of sprintf() rather than just the format string inside printf(), we ensure that the entire output is safely sanitized right before it's echoed out to the screen.

Suggested change
<?php
printf(
/* translators: %s: URL of the paywall / global marketing settings screen. */
wp_kses_post( __( 'Metering is on, but no paywall content is configured, so metered posts show a generic default message. <a href="%s">Set up your paywall</a> to customise what non-members see.', 'memberful' ) ),
esc_url( memberful_wp_plugin_global_marketing_url() )
);
?>
echo wp_kses_post(
sprintf(
/* translators: %s: URL of the paywall / global marketing settings screen. */
__( 'Metering is on, but no paywall content is configured, so metered posts show a generic default message. <a href="%s">Set up your paywall</a> to customise what non-members see.', 'memberful' ),
esc_url( memberful_wp_plugin_global_marketing_url() )
)
);
🤖 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 69 - 75, Update the metering settings output to use late escaping: replace
the current printf/wp_kses_post pattern with sprintf applied to the translated
message and URL, then pass the complete result to wp_kses_post before echoing
it. Preserve the existing translation string, translator comment, and
memberful_wp_plugin_global_marketing_url() value.

Source: Path instructions

@zhickson

Copy link
Copy Markdown
Member

Merge resolution review

Head e5d86616 merges 6f07859c (base) into 01ab0e3f. I reconstructed the automatic merge with git merge-tree and diffed it against the committed result, which isolates exactly which edits were the resolver's rather than either parent's.

The conflict resolution is sound. All seven conflicted files kept both sides, and the two places where the branches genuinely collided (server-rendered vs. cache-safe client-rendered) were reasoned through rather than resolved mechanically. The findings below are mostly consequences of those adaptations, not dropped code.

Everything below was reproduced on a live install with metering enabled, anonymous_limit: 3, registered_limit: 5, apply_to_protected_posts: true, and rules matching all posts. Line numbers are against e5d86616.

Already verified, no need to repeat
  • No leftover conflict markers
  • php -l clean on all 30 changed PHP files
  • npm run build compiles
  • node --test js/metering.test.js passes 10/10
  • Both webpack entries preserved (expiry-banner from base, metering from head)
  • release.sh build_js step retained from base
  • Version triple consistent at 1.80.0
  • readme.txt changelog conflict kept both sides
  • No orphaned teaser-modifier CSS on either side
  • Both Renderer::render() call sites have correct arity
  • Listing pages unaffected by the new anonymous wrapper
  • Branch is up to date with its base

In scope for this PR

1. Free-registration upsell and free-view counter never reach protected metered posts

  • src/paywall/renderer.php:304, should_show_free_registration_cta()
  • src/content_filter.php:408, memberful_metering_paywall_free_view_limit()

The base branch gated the upsell on DECISION_TRIP_METER. That constant is never set for anonymous visitors under this PR's model, since access.php:on_template_redirect only populates $decisions when $user_id is truthy, so rewriting it to current_anon_mode() was necessary and correct. But both call sites were narrowed to RENDER_FREE_METER only.

memberful_metering_wrap_protected() (content_filter.php:345) emits the paywall visible with the body hidden, and metering.js:212-219 only hides it on a successful release. So on a protected metered post the paywall is seen only by visitors the meter blocked, which is exactly the justification the resolution's own comment gives for RENDER_FREE_METER.

Same site, same anonymous request, registered limit above anonymous limit:

Public metered Protected metered
Wrapper data-memberful-metering="free" data-memberful-metering="protected"
Counter "used all 3 of your free articles" absent
Primary CTA "Create a free account" "Subscribe"

Fix: add || RENDER_PROTECTED_SAMPLE === $mode to both checks, or add a comment stating the omission is deliberate. As it stands it reads as an oversight.

2. Countdown block never appears on a released protected sample

  • js/src/metering.js:146-165, hydrateCountdown() uses document.querySelector
  • src/content_filter.php:345-350, wrap_protected() puts __paywall before __content

On a protected post the only countdown element sits inside .memberful-metering__paywall, which precedes .memberful-metering__content in DOM order. Replaying the release sequence from metering.js:212-223 against a live DOM:

querySelector('[data-memberful-countdown]')
  picked element is inside .memberful-metering__paywall  -> true
  that ancestor is hidden at this point                  -> true
  released body's own countdown still hidden             -> true

So hydrateCountdown un-hides an element buried in the hidden paywall and never touches the one in the article the reader is now looking at. Free-meter posts have the reverse ordering so they happen to work; there the block also renders twice, once in the body and once in the teaser embedded in the paywall region.

Fix: scope the lookup to the released container instead of document.

3. Countdown templates render as literal tags for anonymous visitors

  • js/src/blocks/metering-countdown/render.php:24-37, templates written into data-memberful-template*
  • js/src/metering.js:161, assigns via textContent

template is a RichText attribute, so it can legitimately hold markup. The logged-in path renders it with wp_kses_post(); the anonymous path cannot. With a <strong>{count}</strong> template, the live DOM after hydration:

textContent              "You have <strong>2</strong> free articles left."
innerHTML                "You have &lt;strong&gt;2&lt;/strong&gt; free articles left."
querySelector('strong')  null

The reader sees the angle brackets. This pre-existed on the head branch for template, but the resolution extended the same path to singularTemplate and lastArticleTemplate.

Fix: wp_strip_all_tags() before writing the data attributes, since the client cannot honour markup anyway.

4. Counter fields are not gated, and the checkbox is styled as a heading

  • views/paywall/builder-panel.php:93-103
  • src/paywall/preview.php, sample_free_view_limit()
  • stylesheets/admin.css:762, generic label rule

The base branch introduced $metering_enabled and gated its metering fieldset behind it; these two fields went in unconditionally. Reproduced with metering disabled: the "Free registration button" fieldset correctly disappears, while the counter checkbox and message field remain.

In the same block the bare <label> is caught by the generic builder rule, so it computes to display: block, text-transform: uppercase, #6b7280, 13px, identical to the "BUTTON SHAPE" and "ACCENT COLOR" field labels beside it. "SHOW THE FREE-VIEW COUNT" therefore reads as a section heading rather than a checkbox option. The metering settings screen already has .memberful-metering-checkline (admin.css:222) for this case.

Separately, sample_free_view_limit() has no enabled check, so with metering off the builder preview advertises a counter the live paywall will never render, because the filter returns null there. The preview shows the stale stored anonymous_limit, for example "all 3".

Fix: wrap both fields in if ( $metering_enabled ), add a checkbox-line class, and add an enabled check to the preview shim.

5. Cosmetic and changelog

  • views/paywall/builder-panel.php:104, stray double blank line
  • readme.txt, the = unreleased = block covers only this PR's three items. It is missing the base branch's free-registration upsell and the dedicated last / second-to-last countdown messages.

Not this PR, worth separate tickets

Contrast helper picks the less legible option in a band of colours. src/paywall/color.php:14. LUMINANCE_THRESHOLD = 0.179 is the crossover for pure #000000, but TEXT_DARK is #1a1a1a, whose true crossover is 0.20169. Measured for brand colour #24890d:

luminance            0.18295   -> dark text is chosen
contrast vs white    4.51:1    passes WCAG AA
contrast vs dark     3.86:1    fails WCAG AA

Every brand colour with luminance in [0.179, 0.20169) gets the worse of the two options. main carries the same constant, so this is not introduced here; the PR is only what routes it to button labels, which is what made it visible. Fix by deriving the threshold from TEXT_DARK, or by computing both contrast ratios and taking the higher.

Free-registration button cannot be previewed. src/paywall/renderer.php:296. should_show_free_registration_cta() bails on is_user_logged_in(), which is always true in wp-admin, so the builder iframe always shows "Subscribe". Confirmed: the preview CTA is <span>Subscribe</span> even with the fieldset populated. The head branch solved the equivalent problem for the counter by seeding the filter in preview.php; the free-button path has no such shim.

Button shape fallback mismatch. js/src/paywall-builder.js:79 falls back to 'rounded' while src/paywall/config.php:37 defaults to 'square', so a stored invalid shape previews at 8px radius and renders at 0.


Merge risk ahead, worth reading before this branch goes to main

main uses --memberful-brand-text for the button label colour; this branch uses --memberful-button-text. Same feature, two names, each internally consistent across its own renderer.php and paywall.css.

When this branch merges up, that conflicts again in both files. Resolving it one-side-per-file leaves PHP emitting one name while CSS reads the other, which silently falls back to the #fff default and undoes the contrast fix with no error anywhere. Worth resolving both files together and grepping for the variable afterwards.

@cursor

cursor Bot commented Aug 24, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@zhickson
zhickson self-requested a review August 26, 2026 03:50
Comment on lines +204 to +220
public static function incr_counter( string $key, int $ttl ): int {
if ( wp_using_ext_object_cache() ) {
wp_cache_add( $key, 0, self::CACHE_GROUP, $ttl );
$new = wp_cache_incr( $key, 1, self::CACHE_GROUP );
if ( is_int( $new ) ) {
return $new;
}
// Object cache present but without a working incr: fall through to the transient counter. A given backend is
// consistent about incr support, so reads and writes stay on one store (read_counter no longer exists to split
// them). This never returns a constant that would disable enforcement.
}

// Transient counter: atomic where transients are backed by a persistent object cache; best-effort (approximate
// under heavy concurrency) on hosts with no object cache, where WordPress exposes no atomic increment primitive.
// Acceptable for a soft meter — the per-subject ledger cap still bounds each individual visitor regardless.
$value = (int) get_transient( $key ) + 1;
set_transient( $key, $value, $ttl );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@av3nger this seems to be a legit issue. Refreshing the TTL on every increment, and over-limit IPs still bump the global counter. Once the global sample bucket passes 600/min it never expires while traffic continues, so all anonymous visitors may get 429 and no protected content is released until the endpoint sees a full minute of no requests.

add_filter("rss_enclosure", "__return_empty_string");

$memberful_marketing_content = memberful_marketing_content( $post->ID );
$rendered_marketing_content = memberful_wp_resolve_paywall_content( (int) $post->ID );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@av3nger Default paywall message is over-scoped. It's in the general gate, so it applies to every protected post, not just metered ones. Existing teaser-only paywalls will gain an unexpected "Subscribe · Sign in" block on upgrade. The changelog already says it's scoped to metered posts.

Suggested fix: gate it on the metering render modes, or keep it broad and update the changelog.

/* ---------------------------------------------------------
Metering runtime (cache-safe front-end gate)

Free posts keep their body in the cached HTML and toggle to the paywall client-side once the meter trips. Protected

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@av3nger let's remove any comments like this on frontend facing files please, just so we don't give away all our secrets 😄

Also, if metering.css doesn't load the article stays visible with the paywall stacked under it (I confirmed this on my local test). Could we use the hidden attribute like the protected path does?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants