Skip to content

Improve handling of restricted content when publishing - #227

Open
jeherve wants to merge 18 commits into
trunkfrom
fix-jetpack-gated-content-leak
Open

Improve handling of restricted content when publishing#227
jeherve wants to merge 18 commits into
trunkfrom
fix-jetpack-gated-content-leak

Conversation

@jeherve

@jeherve jeherve commented Jul 31, 2026

Copy link
Copy Markdown
Member

Proposed changes:

  • Adds one visitor-independent seam that decides which part of a post's content is safe to publish to the AT Protocol network, and routes every record field through it. When a membership plugin keeps part of a post restricted, that content no longer shows up in what gets published.
  • Jetpack support ships as its own self-contained integration (integrations/class-jetpack.php). Other membership plugins can hook the same atmosphere_publishable_content filter.

Previously, records were built straight from the stored post content, without checking whether any of it was restricted. Background and specifics are in CMA-43.

Other information:

  • Have you written new tests for your changes, if applicable?

Testing instructions:

  • On a site with Jetpack active, create a few posts that restrict their content in different ways: a whole-post subscriber or paid audience, a paywall block partway through, and a paid-content block.
  • Connect the plugin and publish or share each one.
  • Confirm the restricted portions are absent from the resulting records. A fully restricted post shares only a title and link; a partly restricted post shares only its public portion.

Changelog entry

Included in .github/changelog/.

Copilot AI review requested due to automatic review settings July 31, 2026 14:08
@jeherve jeherve self-assigned this Jul 31, 2026
@github-actions github-actions Bot added [Feature] Content Parser Content parser for AT Protocol [Feature] Integrations Third-party plugin integrations [Feature] Transformer AT Protocol record transformers [Tests] Includes Tests PR includes test changes Docs labels Jul 31, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a visitor-independent “publishable content” seam so that paywalled/subscriber-only portions of posts aren’t serialized into public AT Protocol records. It adds a Jetpack integration as the first concrete implementation and routes all body-derived record fields through the new helper.

Changes:

  • Added Atmosphere\get_publishable_content() and the atmosphere_publishable_content filter to centralize gating-safe content selection.
  • Updated transformers and content parsers to build excerpts, rendered HTML, images, and document content from publishable (non-restricted) content.
  • Added a self-contained Jetpack gating integration plus PHPUnit coverage, docs updates, and a patch changelog entry.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/phpunit/tests/integrations/class-test-jetpack.php New integration tests covering Jetpack whole-post gating, paywall split points, and inline premium-content regions end-to-end.
tests/phpunit/tests/class-test-functions.php Adds baseline tests for get_publishable_content() default behavior and filter behavior.
integrations/README.md Documents gating integrations and the Jetpack example.
integrations/class-load.php Registers Jetpack integration when Jetpack memberships are present.
integrations/class-jetpack.php Implements visitor-independent Jetpack paid-content stripping for publishable content.
includes/transformer/class-post.php Uses publishable content for body-derived Bluesky text and image collection.
includes/transformer/class-document.php Uses publishable content for site.standard.document body parsing.
includes/transformer/class-base.php Uses publishable content when generating excerpts and rendered HTML.
includes/functions.php Adds get_publishable_content() helper and filter contract docs.
includes/content-parser/class-parser-base.php Parses/derives rendered HTML from publishable content rather than raw post_content.
.github/changelog/fix-paywalled-content-federation Adds patch changelog entry describing the leak fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread integrations/class-jetpack.php
Comment thread tests/phpunit/tests/class-test-functions.php Outdated
jeherve added 2 commits July 31, 2026 16:37
The Jetpack filter previously parsed the block tree up to four times per
publish: has_block() for the paywall, parse_blocks() to split, then
has_block() + parse_blocks() again to strip subscriber-view regions. Parse
once and drive detection, splitting, and stripping off that single tree.

Detection stays depth-aware via a recursive walk, preserving the fail-closed
behaviour for a paywall block nested below the top level, and ungated content
is still returned verbatim (no serialize round-trip) so non-gated sites keep
publishing exactly as before.

Also wrap the get_publishable_content() filter test assertion in try/finally
so a failure can't leak the filter into later tests.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (2)

integrations/class-jetpack.php:88

  • filter_publishable_content() currently calls parse_blocks() for every post with non-empty content, even when the body contains no Jetpack gating blocks. Since get_publishable_content() is used in multiple transformer/parser paths, this can add noticeable overhead on Jetpack sites for normal public posts. Consider a cheap substring pre-check for the relevant block names so you only parse blocks when the content might actually need splitting/stripping, while still enforcing whole-post access-level gating (fail-closed).
		// Parse the block tree once and reuse it for every check below;
		// detection, splitting, and stripping all read from the same tree
		// rather than re-parsing the content each time.
		$blocks = \parse_blocks( $content );

integrations/class-jetpack.php:61

  • Jetpack::init() is not idempotent, so if Load::register() (or any bootstrap) runs more than once the callback can be added multiple times. This is not just extra work: running filter_publishable_content() twice can change behavior (e.g., a split-point post becomes whole-post gated on the second pass because the paywall marker was removed). Add a guard to ensure the filter is only registered once.
	public static function init(): void {
		\add_filter( 'atmosphere_publishable_content', array( self::class, 'filter_publishable_content' ), 10, 2 );
	}

@pfefferle

Copy link
Copy Markdown
Member

I read through this a few times. The choke point itself looks right to me, my findings are all about what happens after it: the callers were not really updated for the new "body can be empty or narrowed" state. All of this is from reading the code, I did not test it on a real Jetpack site, so please double check the ones that surprise you.

I would fix these before merging

1. A gated short-form post with a featured image publishes an empty record (includes/transformer/class-post.php:1253)

collect_image_attachment_ids() now returns [] in two different cases: the body really has no image blocks, and the body was gated away. build_images_embed() and the '' === $text && null === $embed escape hatch in transform() both still read it the old way. So for a subscribers-only post that is short form (no title, or any post format like Aside/Status) and has a featured image: text is '', the featured-image fallback returns a non-null embed, the link-card fallback never runs, and we publish a record with no text, a bare image and no link back to the post.

2. A nested paywall block empties a post that is not gated at all (integrations/class-jetpack.php:113)

blocks_contain() recurses, but blocks_above_paywall() only walks the top level. If the jetpack/paywall block sits inside a group, $above is null and the filter returns '', no matter what _jetpack_newsletter_access says. The block's "parent": ["core/post-content"] is only an editor restriction, so imports, migrated content, WP-CLI and the REST API can all produce that. A fully public post then publishes an empty body and silently drops to a title and link teaser.

3. the_content puts the paywall back in (includes/content-parser/class-parser-base.php:144, same at includes/transformer/class-base.php:302)

We hand the narrowed string to the_content but leave the original post as the global post, and Jetpack's own add_paywall() runs there at priority 8. Publishing happens in cron, so there is no logged-in user and user_can_view_post() is false. Before this PR the marker was still in the string, so Jetpack returned intro + subscribe form. Now the marker is gone, its strpos() misses and it falls through to return $paywalled_content, so the rendered HTML is only the "Subscribe to keep reading" form. That is what ends up in the document content. The tests do not catch it because Jetpack is not loaded in the suite, only the meta key is faked.

4. The split branch never looks at the access level (integrations/class-jetpack.php:111)

The no-split branch checks is_access_level_public(), the $has_split branch does not. If someone inserts a Paywall block, then sets visibility back to "Everybody" without deleting the block, Jetpack renders the whole post publicly on the site but we truncate at the marker forever. I think gating that branch on ! is_access_level_public( $post ) keeps it fail-closed and stops punishing public posts.

5. The pre-publish preview reads the saved access level (integrations/class-jetpack.php:185)

Pre_Publish_Controller::get_preview() clones the post and overwrites content/title/excerpt with the unsaved editor state. The block checks honour that, but the whole-post gate reads get_post_access_level( $post->ID ), which comes from the last save, and _jetpack_newsletter_access is only written on save. So while drafting a new paid post the panel shows the full body and a long-form strategy, and the published record is a teaser. Jetpack memoizes that lookup per request too.

6. get_excerpt() skips the gate (includes/transformer/class-base.php:230)

The ! empty( $post->post_excerpt ) branch returns before it reaches get_publishable_content(), which does not match the docblock claim that every body-derived field goes through the helper. A manually written excerpt is fine, but SEO plugins and imports store a generated excerpt (the first 55 words of the body) in post_excerpt, and that lands in the document description and the link card. The new test only covers the hand-written case.

Smaller things

7. The registration guard (integrations/class-load.php:41)

The comment above it says Jetpack_Memberships is often not loaded yet at plugins_loaded, which means the second arm of the || is almost never true and the guard is really just defined( 'JETPACK__VERSION' ). On installs where the gating blocks exist without that constant (Simple, jetpack-mu-wpcom style setups), the filter is never registered and gated posts federate in full. Keying off the meta or the blocks, or checking at publish time instead, would be safer.

8. The helper is not cached (includes/functions.php:822)

It is a plain apply_filters() and there are about eight call sites, two of them per transform. With the Jetpack callback attached, each call is a full parse_blocks() plus a serialize_blocks(), and collect_image_attachment_ids() parses the result again. The pre-publish preview endpoint runs on every keystroke, so on a long post this gets expensive. Parser_Base already caches the block tree per post, something similar here would do.

9. The parser contract still says raw content (includes/content-parser/interface-content-parser.php, integrations/README.md)

The interface still tells parser authors they get "raw post content (post_content)" and may re-run the_content themselves. A third-party parser doing exactly what the docs say puts the gated body straight back into the document. Worth updating both, since the docs are the whole point of that extension point.

10. Changelog wording (.github/changelog/fix-paywalled-content-federation)

Two em dashes and "AT Protocol network", and this line goes to the update screen. Something like "Content restricted to subscribers is no longer shared to Bluesky. This covers whole posts, paywalled sections, and paid-content blocks." would fit our rules better.

Resolve the leaks and mismatches raised in review of the restricted-content
work, verified against Jetpack's own paywall and access-level code:

- Gate the paywall split on the stored access level, not the mere presence
  of a jetpack/paywall block. Jetpack renders the whole post to everyone when
  the level is empty/everybody, so a stray or imported block no longer
  truncates a public post; a nested block on a gated post still fails closed.
- Stop Jetpack's own the_content paywall from overwriting the already-narrowed
  body with a "subscribe to keep reading" form: renders now run through
  render_publishable_content(), which unhooks add_paywall for the duration via
  a pre/post render seam.
- Fall back to the link-card composition for a whole-post-gated short-form
  post with a featured image, so it links home instead of shipping a bare
  image with no context.
- Reflect the editor's unsaved access level in the pre-publish preview, so a
  paid post in progress previews as the teaser it will publish rather than the
  still-public last save.
- Register the integration on WordPress.com Simple (IS_WPCOM), where the
  gating blocks ship without JETPACK__VERSION defined.
- Memoize get_publishable_content() per post and content, and route it through
  a single render helper.
- Correct the parser contract docs (parsers receive publishable content; do
  not re-run bare the_content) and the excerpt docblock, and reword the
  changelog for the update screen.

Adds regression coverage for each and corrects a test that encoded the
pre-fix (block-presence) split behavior.
@jeherve

jeherve commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Thanks, this was a genuinely useful pass — all ten landed in 82028ce. I checked the two claims that hinged on Jetpack's runtime (#3, #4) against add_paywall and get_post_access_level/user_can_view_post in Jetpack itself before changing anything, and you were right on both.

Fixed as described:

Two calls I want to flag:

  • Add integrations framework for third-party plugins #4 changed a test. test_split_detects_paywall_block_with_attributes asserted a split with no access meta, which contradicts Jetpack (an inert block on a public post). I switched it to a gated level and added public-post + nested-block regressions.
  • Sync Bluesky replies, likes, and reposts as WordPress comments #6 I took as a docs fix, not a behavior change. A hand-written post_excerpt is the public teaser Jetpack itself surfaces for a gated post, and a test already asserts it's preserved — so I corrected the overclaiming docblocks rather than gating it. If you'd rather fail closed on stored excerpts too, happy to add it.

Regression coverage added for #1, #3, #4, #5, #7. composer lint clean, full suite green (1142 tests).

@pfefferle pfefferle left a comment

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.

Read the whole seam rather than just the diff, and traced every remaining post_content read in includes/transformer/ and includes/content-parser/. What is left is comments, the deliberate is_body_gated() check, and paths that now go through render_publishable_content(). I could not find a way to get gated content into a record.

The shape is right: one visitor-independent decision, every body-derived field routed through it, fail-closed by contract, and the integration reading stored meta instead of viewer state. Suspending Jetpack's own the_content paywall around our render, with the depth counter and the finally, is the part I would most likely have gotten wrong myself.

Two things I would want before merge, both inline. Neither leaks anything.

  1. Post::is_body_gated() compares against '' while Document::get_content() trims, so the two halves of this PR disagree about what empty means. A fully gated split post with whitespace before the paywall block ships as a bare featured image instead of the link card.
  2. The memo cache in get_publishable_content() is never evicted, which walks back the per-batch eviction Backfill_Command does on purpose.

There is a third, latent one in the same inline comment as 2.

What I checked and found clean

build/ is byte-identical to a fresh npm run build, including both asset hashes. PHPCS 40/40, lint:js clean, 23 JS tests, 1145 PHP tests / 3055 assertions. accessLevel is sanitize_key'd and any unrecognised value falls through to gated. Jetpack_Memberships::get_post_access_level() is guarded by class_exists and method_exists. blocks_above_paywall() returns null for a nested marker and the caller fails closed. The serialize_blocks() round-trip is correctly skipped for an untouched public post. The loader's three signals cover Simple, and both files ship in one versioned package behind the path autoloader, so there is no split-deploy window.

Two behaviour changes that I think are right but are worth being deliberate about, both already in the description: an author-written post_excerpt still federates on a fully gated post, and Content_Parser::parse() now gets narrowed content, which is a semantic change for any third-party parser even though the signature is the same.

Risk feels medium to me, only because the seam changes what every body-derived field publishes on every site, not because anything looks unsafe.

Comment thread includes/transformer/class-post.php Outdated
Comment thread includes/functions.php Outdated
jeherve and others added 3 commits August 27, 2026 17:26
Three follow-ups from the review of the gated-content seam, none of
which leak content; they close correctness and memory gaps that turn up
at the edges.

Trim before deciding a body is gated. Post::is_body_gated() compared the
publishable content against '' while Document::get_content() trims first,
so the two halves of the seam disagreed on what "empty" means. A fully
gated split post with whitespace above the paywall block (an import, a
deleted intro paragraph whose newline lingers) serialises to "\n", not
"", so is_body_gated() read false and a titleless post with a featured
image shipped as a bare, contextless image instead of the link card the
check exists to guarantee.

Bound the publishable-content memo. The static cache in
get_publishable_content() was never evicted, which walked back the
per-batch object-cache eviction Backfill_Command does on purpose: a
10k-post run would hold every visited post's content for the life of the
process. Drop the oldest entry once the cache is full.

Vary the memo key on out-of-band state. The key hashed only post_content,
but Jetpack's unsaved access override also decides the output and never
touches the content, so an overridden preview could share — and return —
the saved post's cache slot (fail-open, not closed). Add a filterable
cache-key seam and have the Jetpack integration fold its override in.

Both behaviour fixes carry regression tests.
@pfefferle

Copy link
Copy Markdown
Member

I had a look at this, mostly because I am touching the same publish path in #248. Four things, one of them I think is a real problem.

The access level override always fires (integrations/class-jetpack.php:126)

accessLevel is registered with 'default' => '', and WP_REST_Request::has_param() counts defaults: get_parameter_order() includes 'defaults', and dispatch_request() calls set_default_params() before the callback runs. So has_param( 'accessLevel' ) is true on every dispatched request, also when the client sent nothing. set_access_override() then stores '', is_access_level_public() looks at the override first, and '' === $level is true, so the preview says public no matter what is saved.

What that looks like: a post with _jetpack_newsletter_access set to paid_subscribers, and a request that does not send accessLevel (a cached older build/pre-publish-panel/plugin.js, or a post type where the meta is not in the editor's meta object). The panel shows the full body as what will be published, the actual publish sends only a link card. That is the mismatch the atmosphere_pre_projection docblock says this is there to avoid.

Checking '' !== (string) $request['accessLevel'] instead of has_param() should do it. Worth a look at the customText guard in includes/rest/admin/class-pre-publish-controller.php:270 too, it has the same shape, and its comment says it falls back to the saved meta for an older editor, which has_param() does not actually give you.

Three smaller ones:

  • includes/functions.php:829, the memo key is the post ID plus md5( post_content ), but the answer also depends on _jetpack_newsletter_access, which is not in the key. Change the access level in the same process and you get the earlier, more permissive answer back. Parser_Base has flush_block_cache() for this, there is no equivalent here, so tests cannot reset it either.
  • includes/transformer/class-post.php:1165, is_body_gated() compares raw post_content against '' without trimming. A post whose content is only a newline, with a featured image and no gating at all, comes out as gated and moves from the short form image embed to a link card. Trimming the first clause too should keep the gated case and restore the old behavior.
  • integrations/class-jetpack.php:254, the paywall suspension is a global remove_filter, so a nested the_content for a different post (a query loop in the body, or a shortcode that renders another post) loses its gate too. Probably rare, but worth a note at least.

Happy to push fixes for these directly if that is easier for you, just say the word.

pfefferle and others added 4 commits August 28, 2026 15:49
# Conflicts:
#	build/pre-publish-panel/plugin.asset.php
#	build/pre-publish-panel/plugin.js
…ntent-leak

# Conflicts:
#	build/pre-publish-panel/plugin.asset.php
#	build/pre-publish-panel/plugin.js
Four follow-ups from pfefferle's review. Only the first can leak; the
rest are correctness and accuracy gaps at the edges.

Stop the preview access-level override from always firing. `accessLevel`
and `customText` both register a `''` default, and
`WP_REST_Request::has_param()` counts defaults once the request is
dispatched, so the guards fired on every real request — including one
that never sent the field (an older cached editor build, or a post type
whose meta the editor does not expose). A blank `accessLevel` override
read as "everybody", so the panel showed a saved paid post's full body
while the publish sent only a teaser. Both guards now key off a non-empty
value and otherwise fall back to the saved meta, which fails closed. The
tests missed this because they invoke the controller directly, bypassing
the dispatch that applies defaults.

Trim is_body_gated()'s first clause. It compared raw `post_content`
against `''`, so a post whose stored content is only whitespace, with a
featured image and no gating at all, was mistaken for a fully gated body
and pushed from the short-form image to a link card. Trimming both sides
keeps the gated case and restores the plain short-form path.

Fold the access level into the publishable-content memo key. The key
covered post ID and content hash but not the access level, which also
decides the output, so changing the level in one process returned the
earlier, more permissive answer. The Jetpack cache-key filter now appends
the effective level (override or saved meta). Also adds
flush_publishable_content_cache() so callers and tests can reset the memo,
mirroring the content parser's flush_block_cache().

Document the paywall-suspension limitation. Suspending Jetpack's
`the_content` paywall is global for its duration, so a nested render of a
different, gated post inside our body would lose its gate too. Scoping it
needs a global post that the logged-out cron render does not reliably set,
so the narrow exposure is noted rather than papered over.

The two behaviour fixes carry regression tests that fail without them.
@jeherve

jeherve commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

All four addressed in c199e7e.

The access-level override always firing. You're right, and it's the one that could actually mislead. has_param() counts the '' default once the request is dispatched, so the guard fired even when the client sent nothing, and a blank override read as "everybody". Both guards now key off a non-empty value ('' !== (string) $request['accessLevel'], same for customText) and fall back to the saved meta otherwise, which fails closed. The existing tests never caught it because they call the controller directly and skip the dispatch that fills defaults, so I added two that set the param to its dispatched '' and assert the saved gate still wins.

One nuance on customText: the modern editor always sends the current field value, so a blank now falls back to the saved custom text instead of forcing the default composition. That matches the guard's original comment; the only case it changes is an author clearing the field before saving, where the preview shows the saved text until the save lands (fails safe). Shout if you'd rather it track the cleared field live.

is_body_gated() trimming. Fixed, both sides trim now. A whitespace-only ungated post with a featured image stays on the short-form image path instead of the link card. The regression test ships short-form, not link-card, without the trim.

The memo key. The Jetpack cache-key filter now folds in the effective access level (the override during a preview, the saved meta otherwise), so gating a post mid-process no longer hands back the earlier, more permissive answer. I also added flush_publishable_content_cache() for the reset you noted was missing, with a test through it.

The global paywall suspension. Noted in the docblock rather than fixed. Scoping it to our post needs a reliable global $post inside the nested the_content, which the logged-out cron render doesn't give us, and I didn't want to trade the primary suspension's correctness for the narrow nested-render case. Happy to revisit if you have a clean way to scope it.

I also brought the branch up to date with trunk and resolved the conflicts (build artifacts only) along the way.

…ntent-leak

# Conflicts:
#	build/connectors-card/index.asset.php
#	build/connectors-card/index.js
#	build/reactions/index.asset.php
- Key the parser block/HTML caches on the shared publishable-content
  cache key so a gating change never serves a stale ungated tree.
- Stop federating comments on posts whose body was narrowed by gating.
- Scope the paywall suspension to the posts under render, match the
  paywall callback at any priority and under renamed namespaces, and
  keep inline-rendered posts gated.
- Fire the pre-render and pre-projection actions inside their
  try/finally pairs, and clear the projection HTTP block on throw.
- Treat a client-sent blank customText as a cleared textarea, and send
  an explicit access level from the panel so flipping a gated post back
  to public previews correctly.
WordPress code does not throw in normal operation, so the paired
actions and the HTTP kill-switch removal do not need try/finally.
- New is_post_gated() predicate backs the comment gate, with Jetpack
  flagging non-public access levels the byte comparison cannot see.
- Clear Jetpack's per-post access-level memo before reading it so a
  mid-process change is always picked up.
- Suspend only the known paywall callback, extensible via the
  atmosphere_paywall_content_filters filter, instead of matching by
  name suffix.
- Render a fully gated body to nothing before the_content runs, so
  appender boilerplate cannot ship in any record lane.
- render_publishable_content() sets the global post for its duration,
  so the scoped paywall suspension and inline blocks resolve against
  the post being rendered in every lane.
- The transformer's per-instance HTML and plain-text caches key on the
  shared publishable-content cache key like every other body cache.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Docs [Feature] Content Parser Content parser for AT Protocol [Feature] Integrations Third-party plugin integrations [Feature] Transformer AT Protocol record transformers [Tests] Includes Tests PR includes test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants