From 33d9911dfaf9a7865deeadb5ec90f7b7e6bf879b Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Fri, 7 Aug 2026 20:50:52 +0200 Subject: [PATCH 1/5] Put the enriched head on the page a human sees, without fighting Yoast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app shipped GET /api/delivery/v2/seo-head (2026-08-07): the missing half of the CMS channel. Until now v2 offered only `page` — the whole optimized document, for detected AI crawlers — so `seoAudience: 'all'` had no mechanism behind it on a plugin-served site. The switch read "on", the fix badged "Live", and a browser (or Googlebot) got the untouched origin. The app now fails closed on a `seo_head` capability recorded on the api_keys row at connect time, which this plugin never sent, so every CiteCue WordPress site currently reports a channel that cannot inject. Citecue_Seo_Head closes that. Two rules shape all of it. Never emit a duplicate. WordPress core prints and rel=canonical on its own, before any SEO plugin is involved, and Yoast, Rank Math, AIOSEO, SEOPress, TSF and Slim SEO each print some combination of title, description, OpenGraph and JSON-LD. A second <title> is invalid HTML and a second canonical makes Google pick one arbitrarily. So it fills gaps only: it buffers wp_head from -PHP_INT_MAX to PHP_INT_MAX, maps both sides to slots (title, canonical, each meta name/property, JSON-LD as a whole) and drops every CiteCue tag whose slot is taken. Detecting emitted markup rather than sniffing for WPSEO_VERSION is what makes that correct against plugins and themes nobody here has heard of. merge() is pure, so the contract is tested against real Yoast-, core- and partial-plugin-shaped head dumps rather than through a request. Never block a human. The crawler proxy may spend a network call because only a bot is waiting; here a real visitor is. The render path therefore reads the transient cache and nothing else — a cold URL renders untouched and queues a WP-Cron fetch, so the next visitor gets the tags, and a stale block keeps being printed while the refresh runs. Cold pages cost one un-enriched view, never one slow one. The refresh shares the circuit breaker and the per-minute lookup budget with the crawler path. Also here: - The connect claim reports the capability, and the plugin records what it last reported. A site connected before this release, or one that switches injection off afterwards, disagrees with what CiteCue holds — the settings screen and a Plugins-screen notice ask for the one reconnect that fixes it. - slot_for() is an allowlist, not just a classifier: only application/ld+json scripts, only canonical/alternate links, only meta tags carrying a name/property. This markup arrives over the network and lands in a browser. - Deliberately no DONOTCACHEPAGE. The tags are identical for every visitor, so a page cache storing them is correct — unlike the crawler-only path. - current_url() moves to the container. The page cache and the head cache key off the same string, so two callers spelling a URL differently would be two cache entries for one page. - wp_unschedule_hook(), not wp_clear_scheduled_hook(), clears the refresh jobs: each carries its own URL, so an argument-less clear would match none. 297 tests pass on both the core and WooCommerce passes; phpcs and lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- README.md | 33 +- citecue.php | 7 +- includes/class-citecue-admin.php | 109 ++++- includes/class-citecue-api-client.php | 44 +- includes/class-citecue-cache.php | 76 +++ includes/class-citecue-connect.php | 32 +- includes/class-citecue-plugin.php | 31 ++ includes/class-citecue-proxy.php | 21 +- includes/class-citecue-seo-head.php | 530 +++++++++++++++++++++ includes/class-citecue-settings.php | 42 ++ readme.txt | 29 +- tests/cases/test-connect.php | 91 ++++ tests/cases/test-lifecycle.php | 16 + tests/cases/test-seo-head-delivery.php | 368 ++++++++++++++ tests/cases/test-seo-head-merge.php | 217 +++++++++ tests/includes/class-citecue-http-mock.php | 7 +- tests/includes/class-citecue-test-case.php | 35 ++ uninstall.php | 7 +- 18 files changed, 1647 insertions(+), 48 deletions(-) create mode 100644 includes/class-citecue-seo-head.php create mode 100644 tests/cases/test-seo-head-delivery.php create mode 100644 tests/cases/test-seo-head-merge.php diff --git a/README.md b/README.md index d2d1a78..c7c4c14 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ Middleware between your WordPress site and the AI web. The plugin: 1. **Serves AI-optimized pages to AI bots and crawlers.** When GPTBot, ClaudeBot, PerplexityBot, ChatGPT-User and friends request a page, the plugin fetches the CiteCue-optimized version of that page from the [CiteCue app](https://github.com/henry-mosh/citecue_app) delivery API and serves it instead of the theme output. Human visitors always get your normal site, and any miss, timeout or CiteCue outage falls straight through to the normal page — the integration can never break your site. -2. **Publishes your `llms.txt`** ([llmstxt.org](https://llmstxt.org) convention) at `https://your-site.com/llms.txt`, generated and kept current by CiteCue. -3. **Accepts new content pushed by CiteCue** — content briefs, FAQ packs and other gap-filling pages that promote your brand where AI answers currently miss it — through a signed REST endpoint. Pushed content lands as a **draft** by default so nothing goes live without review. +2. **Enriches your live pages' SEO metadata.** CiteCue's title, meta description, OpenGraph, canonical and JSON-LD tags are added to the `<head>` of the page a *human* sees, so Google and AI answer engines get them too — not only the crawlers served in (1). It fills gaps only: anything WordPress, your theme or your SEO plugin already printed is left exactly as it is. +3. **Publishes your `llms.txt`** ([llmstxt.org](https://llmstxt.org) convention) at `https://your-site.com/llms.txt`, generated and kept current by CiteCue. +4. **Accepts new content pushed by CiteCue** — content briefs, FAQ packs and other gap-filling pages that promote your brand where AI answers currently miss it — through a signed REST endpoint. Pushed content lands as a **draft** by default so nothing goes live without review. ## How it works @@ -17,18 +18,26 @@ AI crawler (GPTBot, ClaudeBot, …) Human visitor │ UA matches AI-crawler registry? ──no──► normal theme output │ │ │yes │ │ ▼ │ -│ GET {app}/api/delivery/v2/page?k=…&u=…&b=… │ -│ Authorization: Bearer ck_live_… X-Citecue-Channel: wordpress │ -│ │200/304: serve optimized HTML (x-citecue: served) │ -│ │404 miss / timeout / error: normal theme output │ +│ GET {app}/api/delivery/v2/page?k=…&u=…&b=… wp_head: │ +│ Authorization: Bearer ck_live_… append cached │ +│ │200/304: serve optimized HTML head tags that │ +│ │404 / timeout: normal theme output fill a gap │ └──────────────────────────────────────────────────────────────────┘ ``` +The two halves never overlap. The left one replaces the whole document, for +detected crawlers only. The right one adds head-only tags to the document the +theme rendered, and CiteCue only serves a block for `enriched` pages in `all` +audience mode — content-parity markup, so adding it is additive rather than +cloaking. + - **One request serves and reports.** The v2 delivery endpoint records the crawler hit server-side (`served` for 200/304, `passthrough` for a miss), so CiteCue's Agent Traffic dashboard stays accurate with no extra beacon. - **Conditional revalidation.** Optimized bodies are cached locally with their ETag; revalidation is a cheap 304 round-trip. Misses are negative-cached for 60 s (mirroring the API's `max-age=60` miss sentinel). - **Circuit breaker.** A timeout or 5xx opens a 60 s circuit (10 min on a rejected key): no API calls, stale cache served when available, plain pass-through otherwise. A CiteCue outage never slows human traffic — the API is only ever called for AI-crawler requests in the first place. - **Abuse-bounded.** Cache keys use CiteCue-compatible URL normalization (tracking params, `www.`, trailing slashes deduped), and outbound lookups are capped by a per-minute budget shared across the crawler and llms.txt paths (default 120, filterable) — neither a spoofed crawler UA spraying unique URLs nor a flood on `/llms.txt` can force unbounded API calls. When CiteCue reports a page is no longer optimized, its cached copy is evicted immediately. - **Crawler registry.** A bundled token list ships with the plugin and refreshes daily from the public `GET /api/delivery/v1/crawlers` feed, so newly added crawlers are served without a plugin update. +- **Metadata never blocks a render.** The `wp_head` path reads the transient cache and nothing else. A URL with no cached block renders untouched and queues a WP-Cron fetch, so the *next* visitor gets the tags; a stale block keeps being printed while the refresh runs. A human page view never waits on CiteCue. +- **Gap-filling, not overriding.** The injector buffers `wp_head`, reads the slots the rest of it filled (title, canonical, each meta name/property, JSON-LD) and drops any CiteCue tag whose slot is taken — detection by output, so it is correct against SEO plugins and themes it has never heard of. It never emits a second `<title>` or a second canonical. - **Verification-compatible.** Served pages carry `X-Citecue: served` and llms.txt carries `X-Citecue: llms-txt` — the headers CiteCue's *Verify installation* button probes for. ## Setup @@ -180,6 +189,7 @@ With WooCommerce active: | `POST /api/delivery/v2/connect/claim` | one-time code | Pairing handshake: code → this site's API key + project | | `GET /api/delivery/v2/config` | `Bearer ck_live_…` | Connection test + project auto-selection by domain | | `GET /api/delivery/v2/page?k&u&b` | `Bearer ck_live_…` + `X-Citecue-Channel: wordpress` | Optimized page for a crawler request (ETag/304; 404 = pass through; hit recorded server-side) | +| `GET /api/delivery/v2/seo-head?k&u` | `Bearer ck_live_…` + `X-Citecue-Channel: wordpress` | Enriched head block for one URL, injected into live pages (204 = nothing to inject; 404 = not optimized; fetched on cron, never on a render) | | `GET /api/delivery/v2/llms.txt?k` | `Bearer ck_live_…` | llms.txt body (ETag/304) | | `GET /api/delivery/v1/crawlers` | none (public) | Daily AI-crawler UA token refresh | @@ -192,14 +202,18 @@ With WooCommerce active: | `citecue_matched_crawler` | filter | Override per-request crawler matching | | `citecue_should_serve` | filter | Veto serving for a specific request | | `citecue_serve_timeout` | filter | Delivery API timeout on the serving path (default 3 s) | -| `citecue_lookup_budget` | filter | Max delivery API lookups per minute across the crawler and llms.txt paths combined (default 120); beyond it, requests are answered from cache or passed through | +| `citecue_lookup_budget` | filter | Max delivery API lookups per minute across the crawler, llms.txt and metadata-refresh paths combined (default 120); beyond it, requests are answered from cache or passed through | +| `citecue_should_inject_seo_head` | filter | Veto SEO head injection for a specific page | +| `citecue_seo_head_tags` | filter | Change the head tags about to be printed, after the gap-fill — the escape hatch for letting CiteCue win a slot your SEO plugin owns | | `citecue_ingest_postarr` | filter | Adjust the post array before insert/update | | `citecue_ingest_rate_limit` | filter | Ingest requests allowed per hour (default 120) | | `citecue_output_meta_description` | filter | Control the meta-description tag for pushed content | ## Performance -The delivery API is only ever called for an AI-crawler request or for `/llms.txt`. A human page view does no HTTP, no extra database query and no cache lookup — the middleware returns as soon as the User-Agent fails to match, having done nothing but a substring scan over the crawler tokens (which travel in an autoloaded option WordPress has already read). +The delivery API is never called while a human is waiting. For an AI-crawler request or `/llms.txt` the call is on the request path, because only a bot is blocked by it. For enriched metadata it is on WP-Cron: the render path reads one transient, and a miss queues the fetch rather than making it. + +A human page view with metadata switched off does no HTTP, no extra database query and no cache lookup — the middleware returns as soon as the User-Agent fails to match, having done nothing but a substring scan over the crawler tokens (which travel in an autoloaded option WordPress has already read). With it switched on, the added cost is one transient read, plus — only when there is something to print — an output buffer over `wp_head` and a regex pass across it. For a crawler request the cost is one API call, with a 3 s timeout, and only when the local cache cannot answer: optimized bodies are cached for 24 h and revalidated with an ETag, misses are negative-cached for 60 s, and llms.txt is treated as fresh for 5 minutes. A repeat crawl of a cached page is a 304, not a re-download. @@ -215,6 +229,7 @@ The worst case for an AI crawler is one 3 s wait per minute. For a human visitor ## Notes & caveats - **Full-page caches / CDNs:** a page cache that serves HTML before WordPress loads will answer AI crawlers with the cached human version. Exclude the AI-crawler user agents from your page cache, or rely on CiteCue's Cloudflare Worker install instead of this plugin when your cache sits in front of PHP. Responses served by this plugin set `DONOTCACHEPAGE` and `Cache-Control: private, no-store` so they are never stored for humans. +- **Full-page caches and enriched metadata:** the injected tags are the same for every visitor, so a page cache storing them is correct and desirable — unlike the crawler path, this one deliberately does *not* set `DONOTCACHEPAGE`. The consequence is that a page cached before its block was warm keeps the un-enriched copy until that cache entry expires. - **Physical `llms.txt`:** a real file in the web root is served by the web server before WordPress runs and therefore wins over the plugin. - **Subdirectory installs:** llms.txt is served at the WordPress root (e.g. `/blog/llms.txt`); the domain-root convention requires a root install (or the Cloudflare Worker). - **Uninstall** removes plugin options and scheduled events; content pushed by CiteCue is your content and is kept. @@ -278,3 +293,5 @@ The second URL must be one CiteCue holds an optimized version of; anything else ### Structure notes `Citecue_Proxy` and `Citecue_Llms_Txt` each split into a `decide()` that returns what should happen and a `serve()` that emits headers and calls `exit`. All the branching lives in `decide()`, which is what the tests drive; `serve()` stays deliberately trivial because nothing can assert against a request that has already ended. + +`Citecue_Seo_Head` follows the same split for the same reason, in three pieces rather than two: `decide()` answers "what should this page get" from cache alone, `refresh()` is the only part that talks to the network (on cron, never on a render), and `merge()` is a pure function from (what the rest of `wp_head` printed, CiteCue's block) to the tags that may be added. `merge()` is the whole conflict-avoidance contract with every other SEO plugin on the site, and being pure is what lets it be tested against a real Yoast head dump without a request in sight. diff --git a/citecue.php b/citecue.php index e3c7353..535b52e 100644 --- a/citecue.php +++ b/citecue.php @@ -2,8 +2,8 @@ /** * Plugin Name: CiteCue AI Auto-Fix * Plugin URI: https://github.com/citecue/wordpress-plugin - * Description: Serves CiteCue-optimized versions of your pages to AI bots and crawlers, publishes your llms.txt, and lets CiteCue push brand-building draft content into WordPress. - * Version: 1.0.3 + * Description: Serves CiteCue-optimized versions of your pages to AI bots and crawlers, adds CiteCue's enriched SEO metadata to your live pages, publishes your llms.txt, and lets CiteCue push brand-building draft content into WordPress. + * Version: 1.1.0 * Requires at least: 5.8 * Requires PHP: 7.4 * Author: CiteCue @@ -56,7 +56,7 @@ static function () { return; } -define( 'CITECUE_VERSION', '1.0.3' ); +define( 'CITECUE_VERSION', '1.1.0' ); define( 'CITECUE_PLUGIN_FILE', __FILE__ ); define( 'CITECUE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); @@ -67,6 +67,7 @@ static function () { require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-api-client.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-connect.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-proxy.php'; +require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-seo-head.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-llms-txt.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-ingest.php'; require_once CITECUE_PLUGIN_DIR . 'includes/class-citecue-admin.php'; diff --git a/includes/class-citecue-admin.php b/includes/class-citecue-admin.php index d4a5d7f..95f7447 100644 --- a/includes/class-citecue-admin.php +++ b/includes/class-citecue-admin.php @@ -134,6 +134,8 @@ public function notices() { . ' <a href="' . esc_url( $this->settings_url() ) . '">' . esc_html__( 'Open settings', 'citecue-ai-auto-fix' ) . '</a></p></div>'; } + $this->seo_head_reconnect_notice(); + if ( ! isset( $_GET['citecue_msg'] ) || ! isset( $_GET['page'] ) || 'citecue' !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only feedback. return; } @@ -174,6 +176,46 @@ public function notices() { . '</p></div>'; } + /** + * Asks for a reconnect when CiteCue's record of this site's SEO-head + * capability is out of date. + * + * CiteCue learns the capability only from the connect exchange, so a site + * that connected before this release injects enriched metadata while the + * app still reports the channel as unable to — and tells the customer their + * "Live" fix is not reaching human visitors. One reconnect fixes it. Shown + * on the Plugins and CiteCue screens only: it is worth acting on, but it is + * not an error, and it has no business following an administrator around + * their whole dashboard. + * + * @return void + */ + private function seo_head_reconnect_notice() { + if ( ! $this->plugin->settings->needs_seo_head_reconnect() ) { + return; + } + + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + $here = $screen ? (string) $screen->id : ''; + if ( 'plugins' !== $here && false === strpos( $here, 'citecue' ) ) { + return; + } + + $enabled = (bool) $this->plugin->settings->get( 'seo_head_enabled' ); + $message = $enabled + ? __( 'this site can now add CiteCue’s enriched title, description, OpenGraph and structured data to your live pages, but CiteCue does not know that yet — until you reconnect, it will keep reporting that your fixes do not reach human visitors.', 'citecue-ai-auto-fix' ) + : __( 'enriched page metadata is switched off here, but CiteCue still expects this site to add it. Reconnect so CiteCue stops reporting metadata it is not getting.', 'citecue-ai-auto-fix' ); + ?> + <div class="notice notice-warning"> + <p> + <strong><?php esc_html_e( 'CiteCue:', 'citecue-ai-auto-fix' ); ?></strong> + <?php echo esc_html( $message ); ?> + </p> + <p><?php $this->action_button( 'citecue_connect_start', __( 'Reconnect to CiteCue', 'citecue-ai-auto-fix' ) ); ?></p> + </div> + <?php + } + /** * Completes a handshake when CiteCue redirects back with a one-time code. * @@ -443,7 +485,7 @@ private function render_setup() { // reads every checkbox as "absent means off" — so the delivery // toggles it does not render travel as hidden inputs rather // than being silently switched off by a save. - $this->preserve_toggles( array( 'serve_enabled', 'llms_txt_enabled', 'ingest_enabled' ) ); + $this->preserve_toggles( array( 'serve_enabled', 'llms_txt_enabled', 'seo_head_enabled', 'ingest_enabled' ) ); ?> <table class="form-table" role="presentation"> <tr> @@ -495,6 +537,28 @@ private function render_connected() { <?php endif; ?> </td> </tr> + <tr> + <th scope="row"><?php esc_html_e( 'Enrich page metadata', 'citecue-ai-auto-fix' ); ?></th> + <td> + <label> + <input type="checkbox" name="<?php echo esc_attr( Citecue_Settings::OPTION ); ?>[seo_head_enabled]" value="1" <?php checked( (bool) $settings->get( 'seo_head_enabled' ) ); ?> /> + <?php esc_html_e( 'Add CiteCue’s title, description, OpenGraph, canonical and structured-data tags to your live pages, so search engines and AI answer engines see them too.', 'citecue-ai-auto-fix' ); ?> + </label> + <p class="description"> + <?php esc_html_e( 'Only fills gaps. Any tag your theme, WordPress or your SEO plugin already outputs is left exactly as it is — CiteCue never emits a second title or canonical.', 'citecue-ai-auto-fix' ); ?> + <?php + $seo_plugin = self::detected_seo_plugin(); + if ( '' !== $seo_plugin ) { + printf( + /* translators: %s: name of the detected SEO plugin. */ + ' ' . esc_html__( '%s is active, so it keeps control of the tags it manages.', 'citecue-ai-auto-fix' ), + esc_html( $seo_plugin ) + ); + } + ?> + </p> + </td> + </tr> <tr> <th scope="row"><?php esc_html_e( 'Serve llms.txt', 'citecue-ai-auto-fix' ); ?></th> <td> @@ -703,6 +767,19 @@ private function render_status_card() { <td><?php esc_html_e( 'Optimized pages', 'citecue-ai-auto-fix' ); ?></td> <td><?php echo $settings->get( 'serve_enabled' ) ? esc_html__( 'Served to AI crawlers', 'citecue-ai-auto-fix' ) : esc_html__( 'Off', 'citecue-ai-auto-fix' ); ?></td> </tr> + <tr> + <td><?php esc_html_e( 'Page metadata', 'citecue-ai-auto-fix' ); ?></td> + <td> + <?php if ( ! $settings->get( 'seo_head_enabled' ) ) : ?> + <?php esc_html_e( 'Off', 'citecue-ai-auto-fix' ); ?> + <?php elseif ( $settings->needs_seo_head_reconnect() ) : ?> + <span style="color:#996800;">—</span> + <?php esc_html_e( 'Enriched here, but CiteCue has not been told yet — reconnect to update it.', 'citecue-ai-auto-fix' ); ?> + <?php else : ?> + <?php esc_html_e( 'Enriched on live pages (gaps only)', 'citecue-ai-auto-fix' ); ?> + <?php endif; ?> + </td> + </tr> <tr> <td><?php esc_html_e( 'llms.txt', 'citecue-ai-auto-fix' ); ?></td> <td> @@ -749,6 +826,36 @@ private function render_status_card() { <?php } + /** + * The name of an active SEO plugin, or '' when none is recognized. + * + * Display only. The injector never asks this question — it reads what + * `wp_head` actually printed, which is the only way to be right about a + * theme or a plugin that is not on this list. Naming the one we do + * recognize just turns "only fills gaps" from a claim into something the + * administrator can check against their own site. + * + * @return string + */ + private static function detected_seo_plugin() { + $known = array( + 'WPSEO_VERSION' => 'Yoast SEO', + 'RANK_MATH_VERSION' => 'Rank Math', + 'AIOSEO_VERSION' => 'All in One SEO', + 'SEOPRESS_VERSION' => 'SEOPress', + 'THE_SEO_FRAMEWORK_VERSION' => 'The SEO Framework', + 'SLIM_SEO_VER' => 'Slim SEO', + ); + + foreach ( $known as $constant => $label ) { + if ( defined( $constant ) ) { + return $label; + } + } + + return ''; + } + /** * The API base row, or a read-only note when wp-config.php pins it. * diff --git a/includes/class-citecue-api-client.php b/includes/class-citecue-api-client.php index 46016fb..24a3f43 100644 --- a/includes/class-citecue-api-client.php +++ b/includes/class-citecue-api-client.php @@ -6,6 +6,7 @@ * (`Authorization: Bearer ck_live_…` + `X-Citecue-Channel: wordpress`): * GET /api/delivery/v2/config — org projects (connection test / selection) * GET /api/delivery/v2/page — optimized page for an AI crawler (ETag/304) + * GET /api/delivery/v2/seo-head — enriched head block for one URL (humans) * GET /api/delivery/v2/llms.txt — llms.txt body (ETag/304) * the public keyless registry feed: * GET /api/delivery/v1/crawlers — AI crawler UA token registry @@ -143,7 +144,7 @@ private function post_json( $url, array $payload, $timeout ) { * the ingest secret at somewhere other than the configured API base. * * @param string $code One-time code from the connect redirect. - * @param array $site {site_url, rest_url, ingest_secret, plugin_version, woocommerce}. + * @param array $site {site_url, rest_url, ingest_secret, plugin_version, woocommerce, seo_head}. * @return array|WP_Error {apiKey, publicKey, domain, ingest?} */ public function claim_connect_code( $code, array $site ) { @@ -260,6 +261,47 @@ public function get_page( $url, $crawler_token, $etag = '' ) { ); } + /** + * GET /api/delivery/v2/seo-head — the enriched head block for one URL. + * + * CiteCue has three distinct empty answers here and they are not + * interchangeable, so they are passed up untouched rather than collapsed: + * 204 means the project is fine but there is nothing to inject right now + * (audience not `all`, or no enriched page for this URL), 404 is the same + * "unknown key / disabled project / bad input" sentinel the page endpoint + * uses, and 401 is a rejected key. A 204 carries no body at all. + * + * @param string $url Absolute URL of the page being rendered. + * @return array|WP_Error {status:int, head:string} + */ + public function get_seo_head( $url ) { + $endpoint = add_query_arg( + array( + 'k' => rawurlencode( (string) $this->settings->get( 'public_key' ) ), + 'u' => rawurlencode( (string) $url ), + ), + $this->settings->api_base() . '/api/delivery/v2/seo-head' + ); + + $result = $this->get( $endpoint, $this->auth_headers(), $this->serve_timeout() ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $head = ''; + if ( 200 === $result['status'] ) { + $data = json_decode( $result['body'], true ); + if ( is_array( $data ) && isset( $data['head'] ) && is_string( $data['head'] ) ) { + $head = $data['head']; + } + } + + return array( + 'status' => $result['status'], + 'head' => $head, + ); + } + /** * GET /api/delivery/v2/llms.txt — the project's llms.txt. * diff --git a/includes/class-citecue-cache.php b/includes/class-citecue-cache.php index d57511d..b9443e1 100644 --- a/includes/class-citecue-cache.php +++ b/includes/class-citecue-cache.php @@ -200,6 +200,82 @@ public function consume_lookup_budget() { return true; } + /** + * Transient key for a URL's SEO head block. Keyed separately from the page + * body: the two have different lifetimes and different eviction triggers — + * a page can stop being injectable (audience switched off) while its + * optimized body is still perfectly servable to crawlers. + * + * @param string $url Absolute page URL. + * @return string + */ + private function seo_head_key( $url ) { + return 'citecue_sh_' . md5( $this->salt() . '|' . self::normalize_url( $url ) ); + } + + /** + * Cached SEO head block for a URL, or null. + * + * @param string $url Absolute page URL. + * @return array{block:string,cached_at:int}|null + */ + public function get_seo_head( $url ) { + $hit = get_transient( $this->seo_head_key( $url ) ); + return ( is_array( $hit ) && isset( $hit['block'] ) ) ? $hit : null; + } + + /** + * Stores a URL's SEO head block. + * + * @param string $url Absolute page URL. + * @param string $block Head markup. + * @return void + */ + public function set_seo_head( $url, $block ) { + set_transient( + $this->seo_head_key( $url ), + array( + 'block' => (string) $block, + 'cached_at' => time(), + ), + self::BODY_TTL + ); + } + + /** + * Removes a cached SEO head block. Called whenever CiteCue says it has + * nothing for the URL, so a block from before the audience switch was + * flipped (or before the page was unapproved) cannot keep being printed on + * a live page for the rest of the day. + * + * @param string $url Absolute page URL. + * @return void + */ + public function delete_seo_head( $url ) { + delete_transient( $this->seo_head_key( $url ) ); + } + + /** + * Whether CiteCue recently said it has no head block for this URL. + * + * @param string $url Absolute page URL. + * @return bool + */ + public function is_recent_seo_head_miss( $url ) { + return (bool) get_transient( 'citecue_shm_' . md5( $this->salt() . '|' . self::normalize_url( $url ) ) ); + } + + /** + * Records that CiteCue has no head block for this URL. Mirrors the API's + * `max-age=60` on both of its empty answers (204 and the 404 sentinel). + * + * @param string $url Absolute page URL. + * @return void + */ + public function set_seo_head_miss( $url ) { + set_transient( 'citecue_shm_' . md5( $this->salt() . '|' . self::normalize_url( $url ) ), 1, self::MISS_TTL ); + } + /** * Cached llms.txt, or null. * diff --git a/includes/class-citecue-connect.php b/includes/class-citecue-connect.php index 6c3c8ab..a0af591 100644 --- a/includes/class-citecue-connect.php +++ b/includes/class-citecue-connect.php @@ -143,6 +143,13 @@ public function verify_state( $state ) { public function claim( $code ) { $settings = $this->plugin->settings; + // The capability, not the ambition: CiteCue uses this to decide whether + // `seoAudience: 'all'` is a promise this channel can keep, and a site + // that has injection switched off keeps it no better than a plugin that + // cannot inject at all. Reporting the live setting is what stops the + // app badging a fix "Live" over a head nothing writes to. + $seo_head = (bool) $settings->get( 'seo_head_enabled' ); + $result = $this->plugin->api->claim_connect_code( $code, array( @@ -151,6 +158,7 @@ public function claim( $code ) { 'ingest_secret' => $settings->ensure_ingest_secret(), 'plugin_version' => CITECUE_VERSION, 'woocommerce' => class_exists( 'WooCommerce' ), + 'seo_head' => $seo_head, ) ); @@ -159,9 +167,13 @@ public function claim( $code ) { } $update = array( - 'api_key' => $result['apiKey'], - 'public_key' => $result['publicKey'], - 'project_domain' => $result['domain'], + 'api_key' => $result['apiKey'], + 'public_key' => $result['publicKey'], + 'project_domain' => $result['domain'], + // Only after the exchange succeeded: a failed claim wrote nothing + // on CiteCue's side, so recording it here would silence the + // reconnect prompt for a capability the app never learned about. + 'seo_head_reported' => $seo_head, ); // CiteCue's connect screen is where the customer is told that content @@ -200,11 +212,15 @@ public function disconnect() { // flag — but sanitize() only runs once register_setting() has, // and the empty value is what a write that bypasses the filter // has to see. Either path must end up with no key. - 'api_key' => '', - 'api_key_clear' => 1, - 'public_key' => '', - 'project_domain' => '', - 'ingest_enabled' => false, + 'api_key' => '', + 'api_key_clear' => 1, + 'public_key' => '', + 'project_domain' => '', + 'ingest_enabled' => false, + // Back to "never reported": the next connection mints a new key + // with its own capabilities, and what the old one recorded says + // nothing about it. + 'seo_head_reported' => null, ) ); diff --git a/includes/class-citecue-plugin.php b/includes/class-citecue-plugin.php index fc4f658..10ce133 100644 --- a/includes/class-citecue-plugin.php +++ b/includes/class-citecue-plugin.php @@ -91,6 +91,7 @@ private function __construct() { ( new Citecue_Llms_Txt( $this ) )->register(); ( new Citecue_Proxy( $this ) )->register(); + ( new Citecue_Seo_Head( $this ) )->register(); ( new Citecue_Ingest( $this ) )->register(); if ( is_admin() ) { @@ -101,6 +102,31 @@ private function __construct() { add_action( self::CRON_HOOK, array( $this, 'daily_sync' ) ); } + /** + * The absolute URL of the current request, as the delivery API should be + * asked about it. CiteCue normalizes it server-side (scheme/www/trailing + * slash/tracking params), so this only has to be faithful. + * + * Lives on the container because two callers need it — the crawler proxy + * and the SEO head injector — and they must agree to the character: the + * page cache and the head cache key off the same string, and a URL the two + * spell differently is two cache entries for one page. + * + * @return string + */ + public static function current_url() { + if ( ! isset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ) ) { + return ''; + } + $scheme = is_ssl() ? 'https' : 'http'; + $host = sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ); + // esc_url_raw(), not sanitize_text_field(): the latter deletes every + // percent-encoded sequence it finds, so /caf%C3%A9/ would reach CiteCue + // as /caf/ and be cached under the wrong key. + $uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); + return esc_url_raw( $scheme . '://' . $host . $uri ); + } + /** * Init: cron self-heal. * @@ -158,5 +184,10 @@ public static function activate() { */ public static function deactivate() { wp_clear_scheduled_hook( self::CRON_HOOK ); + // Queued metadata refreshes, one per URL. wp_unschedule_hook(), not + // wp_clear_scheduled_hook(): the latter only clears events whose + // arguments match the ones passed, and every one of these carries its + // own URL, so an argument-less call would clear none of them. + wp_unschedule_hook( Citecue_Seo_Head::REFRESH_HOOK ); } } diff --git a/includes/class-citecue-proxy.php b/includes/class-citecue-proxy.php index 6179608..6bddd42 100644 --- a/includes/class-citecue-proxy.php +++ b/includes/class-citecue-proxy.php @@ -82,7 +82,7 @@ public function decide() { return self::pass( 'not-a-crawler' ); } - $url = $this->current_url(); + $url = Citecue_Plugin::current_url(); if ( '' === $url ) { return self::pass( 'no-url' ); } @@ -296,25 +296,6 @@ private function is_excluded_woocommerce_request() { return false; } - /** - * The absolute URL of the current request. CiteCue normalizes it - * server-side (scheme/www/trailing-slash/tracking params). - * - * @return string - */ - private function current_url() { - if ( ! isset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ) ) { - return ''; - } - $scheme = is_ssl() ? 'https' : 'http'; - $host = sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ); - // esc_url_raw(), not sanitize_text_field(): the latter deletes every - // percent-encoded sequence it finds, so /caf%C3%A9/ would reach CiteCue - // as /caf/ and be cached under the wrong key. - $uri = esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ); - return esc_url_raw( $scheme . '://' . $host . $uri ); - } - /** * Emits the optimized document and ends the request. Stamps the * `X-Citecue: served` header CiteCue's install verifier looks for, and diff --git a/includes/class-citecue-seo-head.php b/includes/class-citecue-seo-head.php new file mode 100644 index 0000000..61ec61f --- /dev/null +++ b/includes/class-citecue-seo-head.php @@ -0,0 +1,530 @@ +<?php +/** + * Injects CiteCue's enriched SEO head block into ordinary page loads. + * + * This is the human-facing half of the delivery channel, and the opposite of + * Citecue_Proxy in every way that matters. The proxy replaces the whole + * document, for detected AI crawlers only. This one adds a handful of + * head-only tags — title, meta description, OpenGraph, canonical, JSON-LD — + * to the page a browser (or Googlebot) already sees, leaving the visible page + * untouched. CiteCue only serves a block for `enriched` pages in `all` + * audience mode, which is what keeps the two apart: enriched markup is + * content-parity and additive, so adding it is not cloaking, whereas serving a + * rewritten document to a human would be. + * + * Two rules govern everything below. + * + * **Never emit a duplicate.** WordPress core prints `<title>` and + * `<link rel="canonical">` on its own, and Yoast, Rank Math, AIOSEO, SEOPress, + * The SEO Framework, Slim SEO and Jetpack each print some combination of + * title, description, OpenGraph and JSON-LD. A second `<title>` is invalid + * HTML and a second canonical makes Google pick one arbitrarily — so this + * fills gaps only: it captures what the rest of `wp_head` actually printed and + * drops every CiteCue tag whose slot is already taken. Detecting emitted + * markup rather than sniffing for `WPSEO_VERSION` is what makes that correct + * against SEO plugins and themes nobody here has heard of. + * + * **Never block a human.** The proxy may spend a request budget on an outbound + * call because only a bot is waiting. Here a real visitor is, so the render + * path reads the transient cache and nothing else: a miss injects nothing and + * schedules a background refresh, so the next visitor gets the block. Cold + * pages cost one un-enriched view, never one slow one. + * + * @package Citecue + */ + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +/** + * Live-page SEO head injection. + */ +class Citecue_Seo_Head { + + /** How long a cached block stays fresh before a background refresh (matches the API's max-age=300). */ + const FRESH_SECONDS = 5 * MINUTE_IN_SECONDS; + + /** Cron hook for the out-of-band fetch. */ + const REFRESH_HOOK = 'citecue_refresh_seo_head'; + + /** How long one URL's refresh request is suppressed, so a burst of visitors schedules one job. */ + const REFRESH_LOCK_TTL = MINUTE_IN_SECONDS; + + /** + * `wp_head` priority the output capture opens at. Below every priority in + * practical use — core's `_wp_render_title_tag` is 1, `rel_canonical` 10, + * and the SEO plugins cluster around 1 — so the capture sees all of it. + */ + const CAPTURE_START_PRIORITY = -PHP_INT_MAX; + + /** `wp_head` priority the capture closes and injects at: after everyone. */ + const CAPTURE_END_PRIORITY = PHP_INT_MAX; + + /** + * `<link>` relations that may be injected. Anything else in the block is + * dropped: the response is trusted markup for THIS site's head, but it + * reaches a human's browser, so the set of things it may add is the small + * set it needs (`stylesheet`, `preload` and friends have no business + * arriving from a metadata endpoint). + */ + const ALLOWED_LINK_RELS = array( 'canonical', 'alternate' ); + + /** + * Plugin container. + * + * @var Citecue_Plugin + */ + private $plugin; + + /** + * Output-buffer nesting level our capture opened at, or null when no + * capture is in flight. + * + * @var int|null + */ + private $buffer_level = null; + + /** + * The decision start_capture() acted on, carried to finish_capture() so the + * pair cannot disagree — and so one page load costs one cache read and, at + * most, one scheduled refresh. + * + * @var array|null + */ + private $decision = null; + + /** + * Constructor. + * + * @param Citecue_Plugin $plugin Plugin container. + */ + public function __construct( Citecue_Plugin $plugin ) { + $this->plugin = $plugin; + } + + /** + * Hooks the capture and the background refresh worker. + * + * @return void + */ + public function register() { + add_action( self::REFRESH_HOOK, array( $this, 'refresh' ), 10, 1 ); + add_action( 'wp_head', array( $this, 'start_capture' ), self::CAPTURE_START_PRIORITY ); + add_action( 'wp_head', array( $this, 'finish_capture' ), self::CAPTURE_END_PRIORITY ); + } + + /** + * Opens the capture, but only when there is something to inject — buffering + * a page we will not touch is pure overhead, and every reason not to inject + * is knowable before the first byte of `wp_head`. + * + * @return void + */ + public function start_capture() { + $this->buffer_level = null; + $this->decision = $this->decide(); + + if ( ! $this->decision['inject'] ) { + return; + } + + ob_start(); + $this->buffer_level = ob_get_level(); + } + + /** + * Closes the capture, re-emits everything the rest of `wp_head` printed, + * and appends the CiteCue tags that found an empty slot. + * + * @return void + */ + public function finish_capture() { + $level = $this->buffer_level; + $decision = $this->decision; + $this->buffer_level = null; + $this->decision = null; + + if ( null === $level || null === $decision ) { + return; + } + + // Someone else's buffer is still open on top of ours (or ours was + // closed for us). Either way the levels no longer line up, so take the + // conservative exit: flush whatever we own so no output is lost or + // reordered, and inject nothing this request. A missing block is a + // non-event; mangled `<head>` output is not. + if ( ob_get_level() !== $level ) { + while ( ob_get_level() >= $level && ob_get_level() > 0 ) { + ob_end_flush(); + } + return; + } + + $head = (string) ob_get_clean(); + $tags = self::merge( $head, $decision['block'] ); + + // Everything the rest of wp_head printed, verbatim — this is other + // plugins' and core's own output passing straight back through. + echo $head; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + + if ( ! $tags ) { + return; + } + + echo "\n<!-- CiteCue -->\n"; + // Head-only markup generated by CiteCue for this site's own page, and + // narrowed to an allowlist of tag shapes by self::slot_for() — output + // verbatim by design (escaping would destroy the markup). + echo implode( "\n", $tags ) . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + /** + * Whether this request should be injected into, and with what — reading the + * cache only, never the network. The testable counterpart of the capture + * pair, mirroring the decide()/serve() split in Citecue_Proxy. + * + * @return array{inject:bool,block:string,reason:string} + */ + public function decide() { + $settings = $this->plugin->settings; + if ( ! $settings->get( 'seo_head_enabled' ) || ! $settings->is_delivery_configured() ) { + return self::skip( 'not-configured' ); + } + if ( ! $this->is_eligible_request() ) { + return self::skip( 'not-eligible' ); + } + + $url = Citecue_Plugin::current_url(); + if ( '' === $url ) { + return self::skip( 'no-url' ); + } + + /** + * Filters whether to inject CiteCue's SEO head into this page. + * + * @param bool $should_inject Default true. + * @param string $url Absolute request URL. + */ + if ( ! apply_filters( 'citecue_should_inject_seo_head', true, $url ) ) { + return self::skip( 'vetoed' ); + } + + $cache = $this->plugin->cache; + $cached = $cache->get_seo_head( $url ); + + if ( $cached && $cache->is_fresh( $cached, self::FRESH_SECONDS ) ) { + return self::block( $cached['block'], 'cached' ); + } + + // CiteCue recently said it has nothing for this URL. Unlike the crawler + // path this one runs on every human page view, so without the negative + // cache an un-enriched site would schedule a refresh job per visitor. + if ( $cache->is_recent_seo_head_miss( $url ) ) { + return self::skip( 'recent-miss' ); + } + + $this->schedule_refresh( $url ); + + // Stale-while-revalidate: a day-old block is still this page's own + // metadata, and withholding it while the refresh runs would blank the + // tags on every page during a CiteCue outage. + return $cached ? self::block( $cached['block'], 'stale' ) : self::skip( 'no-cache' ); + } + + /** + * Fetches one URL's block out of band. Runs on cron, never on a page + * render, so this is the only place here that may take a network round + * trip — and it shares the circuit breaker and the per-minute lookup budget + * with the crawler path, so the site's total outbound calls stay bounded + * however traffic is distributed between the two. + * + * @param string $url Absolute page URL. + * @return string Outcome (diagnostic only, and what the tests assert on). + */ + public function refresh( $url ) { + $url = (string) $url; + $settings = $this->plugin->settings; + + if ( ! $settings->get( 'seo_head_enabled' ) || ! $settings->is_delivery_configured() ) { + return 'not-configured'; + } + + $cache = $this->plugin->cache; + if ( $cache->is_circuit_open() ) { + return 'circuit-open'; + } + if ( ! $cache->consume_lookup_budget() ) { + return 'budget-exhausted'; + } + + $response = $this->plugin->api->get_seo_head( $url ); + + if ( is_wp_error( $response ) ) { + $cache->trip_circuit(); + return 'transport-error'; + } + + switch ( $response['status'] ) { + case 200: + if ( '' === $response['head'] ) { + // A 200 carrying no block is a payload we do not understand. + // Treat it as "nothing to inject" rather than caching an + // empty string that would read as a valid block. + $cache->delete_seo_head( $url ); + $cache->set_seo_head_miss( $url ); + return 'empty'; + } + $cache->set_seo_head( $url, $response['head'] ); + return 'fresh'; + + case 204: + // Valid project, nothing to inject right now — the audience is + // not `all`, or this URL has no enriched page. Evict, so a block + // from before the switch was flipped cannot keep appearing. + $cache->delete_seo_head( $url ); + $cache->set_seo_head_miss( $url ); + return 'nothing-to-inject'; + + case 404: + $cache->delete_seo_head( $url ); + $cache->set_seo_head_miss( $url ); + return 'not-optimized'; + + case 401: + update_option( 'citecue_auth_failed', time(), false ); + $cache->trip_circuit( Citecue_Cache::AUTH_CIRCUIT_TTL ); + return 'unauthorized'; + + default: + $cache->trip_circuit(); + return 'server-error'; + } + } + + /** + * The CiteCue tags that may be added to a head that already contains + * `$existing`: every tag whose slot — the title, the canonical, one meta + * name/property, JSON-LD as a whole — nothing else has claimed. + * + * Pure, and deliberately so: this is the whole conflict-avoidance contract + * with every other SEO plugin on the site, and it is worth being able to + * test it against a real Yoast head dump without a request in sight. + * + * @param string $existing Markup the rest of wp_head printed. + * @param string $block CiteCue's head block. + * @return string[] Tags to append, in the order CiteCue sent them. + */ + public static function merge( $existing, $block ) { + $occupied = self::slots_in( $existing ); + $tags = array(); + + foreach ( self::tags_in( $block ) as $tag ) { + $slot = self::slot_for( $tag ); + if ( '' === $slot || isset( $occupied[ $slot ] ) ) { + continue; + } + // Claim it here too, so a block that somehow carries two canonicals + // cannot contribute both. + $occupied[ $slot ] = true; + $tags[] = $tag; + } + + /** + * Filters the CiteCue head tags about to be printed. + * + * The default policy is gap-filling: a tag whose slot another plugin + * has already filled is dropped. Use this to re-add one (having removed + * the other plugin's copy yourself) or to drop more. + * + * @param string[] $tags Tags that survived the gap-fill. + * @param string $block The full block CiteCue returned. + * @param string $existing Markup the rest of wp_head printed. + */ + $tags = apply_filters( 'citecue_seo_head_tags', $tags, $block, $existing ); + + return is_array( $tags ) ? array_values( array_filter( $tags, 'is_string' ) ) : array(); + } + + /** + * Splits head markup into the individual elements this class reasons about. + * + * @param string $html Head markup. + * @return string[] + */ + private static function tags_in( $html ) { + $pattern = '#<title\b[^>]*>.*?|]*>.*?|<(?:meta|link)\b[^>]*>#is'; + return preg_match_all( $pattern, (string) $html, $matches ) ? $matches[0] : array(); + } + + /** + * The slots occupied by existing head markup. + * + * @param string $html Head markup. + * @return array + */ + private static function slots_in( $html ) { + $slots = array(); + foreach ( self::tags_in( $html ) as $tag ) { + $slot = self::slot_for( $tag, true ); + if ( '' !== $slot ) { + $slots[ $slot ] = true; + } + } + return $slots; + } + + /** + * The slot one element claims, or '' for an element that claims none. + * + * Reading the same element two ways on purpose. Scanning what other plugins + * printed ($lenient) only asks "is this slot taken", so any `` rel and + * any `', + ) + ); + } + + /** + * An empty head takes the whole block. + * + * @return void + */ + public function test_empty_head_takes_every_tag() { + $tags = Citecue_Seo_Head::merge( '', $this->block() ); + + $this->assertCount( 7, $tags ); + $this->assertStringContainsString( 'Acme Widgets — Industrial fasteners', $tags[0] ); + } + + /** + * WordPress alone — no SEO plugin at all — already prints a title and a + * canonical, so those two are the baseline conflict on every site. + * + * @return void + */ + public function test_core_title_and_canonical_are_never_duplicated() { + $existing = 'Hello world — Example' . "\n" + . ''; + + $tags = Citecue_Seo_Head::merge( $existing, $this->block() ); + $all = implode( "\n", $tags ); + + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'rel="canonical"', $all ); + // Everything core does not emit still comes through. + $this->assertStringContainsString( 'name="description"', $all ); + $this->assertStringContainsString( 'og:title', $all ); + } + + /** + * A Yoast-shaped head: title, description, canonical, OpenGraph, Twitter + * and a JSON-LD graph. Nothing of ours may survive it. + * + * @return void + */ + public function test_yoast_shaped_head_leaves_nothing_to_add() { + $existing = implode( + "\n", + array( + 'Hello world - Example', + '', + '', + '', + '', + '', + '', + ) + ); + + $this->assertSame( array(), Citecue_Seo_Head::merge( $existing, $this->block() ) ); + } + + /** + * The realistic middle case: an SEO plugin that manages the basics but + * emits no OpenGraph and no structured data. Those are exactly the gaps + * CiteCue exists to fill. + * + * @return void + */ + public function test_partial_seo_plugin_gets_only_its_gaps_filled() { + $existing = implode( + "\n", + array( + 'Hello world', + '', + '', + ) + ); + + $tags = Citecue_Seo_Head::merge( $existing, $this->block() ); + $all = implode( "\n", $tags ); + + $this->assertCount( 4, $tags ); + $this->assertStringContainsString( 'og:title', $all ); + $this->assertStringContainsString( 'og:description', $all ); + $this->assertStringContainsString( 'twitter:card', $all ); + $this->assertStringContainsString( 'application/ld+json', $all ); + } + + /** + * Attribute quoting and casing vary wildly across themes and plugins, and a + * slot missed because of a single quote is a duplicate tag shipped. + * + * @return void + */ + public function test_slot_detection_survives_quoting_and_casing() { + $existing = "Hi\n\n"; + + $tags = Citecue_Seo_Head::merge( $existing, $this->block() ); + $all = implode( "\n", $tags ); + + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'rel="canonical"', $all ); + $this->assertStringNotContainsString( 'name="description"', $all ); + } + + /** + * A plain `'; + + $all = implode( "\n", Citecue_Seo_Head::merge( $existing, $this->block() ) ); + + $this->assertStringContainsString( 'application/ld+json', $all ); + } + + /** + * The response is trusted markup for this site's own head, but it arrives + * over the network and lands in a human's browser, so only recognized tag + * shapes may be printed. + * + * @return void + */ + public function test_unexpected_markup_in_the_block_is_dropped() { + $block = implode( + "\n", + array( + '', + '', + '', + '', + '', + ) + ); + + $tags = Citecue_Seo_Head::merge( '', $block ); + + $this->assertCount( 1, $tags ); + $this->assertStringContainsString( 'og:title', $tags[0] ); + } + + /** + * A block that somehow carries the same slot twice contributes it once. + * + * @return void + */ + public function test_block_internal_duplicates_collapse() { + $block = '' + . ''; + + $tags = Citecue_Seo_Head::merge( '', $block ); + + $this->assertCount( 1, $tags ); + $this->assertStringContainsString( 'example.org/a', $tags[0] ); + } + + /** + * The escape hatch for a site that wants CiteCue to win: remove the other + * plugin's tag yourself, then re-add ours through the filter. + * + * @return void + */ + public function test_filter_can_override_the_gap_fill() { + $existing = 'Theirs'; + + add_filter( + 'citecue_seo_head_tags', + static function ( $tags, $block ) { + $tags[] = ''; + unset( $block ); + return $tags; + }, + 10, + 2 + ); + + $all = implode( "\n", Citecue_Seo_Head::merge( $existing, $this->block() ) ); + + $this->assertStringContainsString( 'citecue-test', $all ); + } +} diff --git a/tests/includes/class-citecue-http-mock.php b/tests/includes/class-citecue-http-mock.php index b368262..9c3a3a8 100644 --- a/tests/includes/class-citecue-http-mock.php +++ b/tests/includes/class-citecue-http-mock.php @@ -68,7 +68,7 @@ public function disable() { /** * Queues a response for an endpoint. * - * @param string $endpoint One of page|llms|config|crawlers. + * @param string $endpoint One of page|seo_head|llms|config|crawlers. * @param int $status HTTP status code. * @param string $body Response body. * @param array $headers Response headers (case-insensitive). @@ -86,7 +86,7 @@ public function queue( $endpoint, $status, $body = '', array $headers = array() /** * Queues a transport failure (timeout, DNS error, refused connection). * - * @param string $endpoint One of page|llms|config|crawlers. + * @param string $endpoint One of page|seo_head|llms|config|crawlers. * @param string $message Error message. * @return $this */ @@ -190,6 +190,9 @@ private static function classify( $url ) { if ( false !== strpos( $url, '/api/delivery/v2/connect/claim' ) ) { return 'connect'; } + if ( false !== strpos( $url, '/api/delivery/v2/seo-head' ) ) { + return 'seo_head'; + } if ( false !== strpos( $url, '/api/delivery/v2/page' ) ) { return 'page'; } diff --git a/tests/includes/class-citecue-test-case.php b/tests/includes/class-citecue-test-case.php index 68e60a7..bbebf6f 100644 --- a/tests/includes/class-citecue-test-case.php +++ b/tests/includes/class-citecue-test-case.php @@ -166,6 +166,41 @@ protected function llms_txt() { return new Citecue_Llms_Txt( $this->plugin ); } + /** + * A SEO head injector bound to the plugin under test. + * + * @return Citecue_Seo_Head + */ + protected function seo_head() { + return new Citecue_Seo_Head( $this->plugin ); + } + + /** + * Turns the current request into an ordinary human page view of a real + * published post. + * + * A real post, not just a path: the injector refuses to touch a 404, so a + * URL with nothing behind it would make every one of its tests pass for the + * wrong reason. + * + * @param string $path Request path, or '' to create a post and visit it. + * @return string The absolute URL of the faked request. + */ + protected function fake_visitor_request( $path = '' ) { + $url = '' !== $path + ? home_url( $path ) + : (string) get_permalink( self::factory()->post->create( array( 'post_status' => 'publish' ) ) ); + + // go_to() runs the main query, so is_404()/is_search()/is_feed() — which + // the injector's eligibility check consults — answer truthfully. + $this->go_to( $url ); + + $_SERVER['REQUEST_METHOD'] = 'GET'; + unset( $_SERVER['HTTP_USER_AGENT'] ); + + return $url; + } + /** * Asserts the proxy left the request to WordPress, for the stated reason. * diff --git a/uninstall.php b/uninstall.php index 229eced..3996fc5 100644 --- a/uninstall.php +++ b/uninstall.php @@ -23,6 +23,11 @@ delete_transient( 'citecue_circuit' ); delete_transient( 'citecue_ingest_rate' ); delete_transient( 'citecue_connect_state' ); -// Page/llms.txt transients are salt-keyed and expire on their own within a day. +// Page/llms.txt/SEO-head transients are salt-keyed and expire on their own +// within a day. wp_clear_scheduled_hook( 'citecue_daily_sync' ); +// Single events, one per URL awaiting a metadata refresh. wp_unschedule_hook() +// rather than wp_clear_scheduled_hook(), which only clears events whose +// arguments match the ones passed — each of these carries its own URL. +wp_unschedule_hook( 'citecue_refresh_seo_head' ); From 366a5e470d25eab0c5f9f0492913fd123532e859 Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Fri, 7 Aug 2026 21:28:10 +0200 Subject: [PATCH 2/5] Address the PR #10 review: build the tags, don't just inspect them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings, and the first three change what this feature is allowed to do. Rebuild every tag instead of echoing the response's own markup. The allowlist was implemented by searching a tag for `rel=`/`name=` and, if it liked what it found, printing the original string — and a search cannot read markup. `\brel` matched `data-rel="canonical"`, and even anchored to whitespace it still finds ` rel=canonical` inside `foo="a rel=canonical"`. Either way a `` was authorized as a canonical and printed verbatim onto a real visitor's page. Attributes are now parsed by walking name/value pairs left to right, which cannot mistake a quoted value for a name, and nothing survives that this class did not itself write: link hrefs are esc_url_raw'd to http/https, meta values are escaped into a tag we build, JSON-LD is re-encoded from the decoded data with JSON_HEX_TAG so it cannot end its own script element, and every other attribute is discarded. Stop sending transactional URLs to CiteCue. The injector had no WooCommerce exclusion, so an order-received or account page put its full URL — order id, `wc_order_*` key, account token — into a cron argument and then into the `u=` parameter. The proxy's exclusion moves to the container and both paths share it. Independently of that, the lookup URL now drops every query argument WordPress does not recognize as a query variable, so a token in the address is never cached, queued or sent whatever page it is on. Bound the refresh queue. The per-URL lock bounds one URL, not the number of URLs, and the outbound budget is only spent when a job runs — so `/?x=`, which renders the homepage, could push unlimited events into WordPress's serialized cron option and make every later write more expensive. Stripping unrecognized query arguments removes most of the ways to mint a distinct URL; a second per-minute budget bounds what is left. And three correctness fixes: - Never unwind a buffer this class did not open. The mismatch branch flushed down to our level, closing buffers belonging to whatever minifier or cache opened them; their later ob_get_clean() would then take an unrelated one. It now leaves every buffer alone and skips injection for that request. - Capture from template_redirect, not from the start of wp_head. A theme that prints , a canonical or its own OpenGraph in header.php does so before wp_head runs, so a capture scoped to the action read those slots as empty and appended the duplicate the gap-fill exists to prevent. Still not a whole-page buffer: wp_head sits in <head>, so it closes within the first few kilobytes. - Flush the delivery cache when the selected project changes. Page bodies, llms.txt and head blocks all key off the cache salt and the URL and NOT the project, so repointing a site at another project served the previous one's content under the new one's name for up to a day. Fixed where the project changes rather than in one cache's key, because all three had it. Also skips password-protected posts: CiteCue has no metadata for content it cannot read, and a description of it does not belong on the gate. 316 tests pass on both passes; phpcs and lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- README.md | 10 +- includes/class-citecue-cache.php | 30 ++ includes/class-citecue-plugin.php | 32 ++ includes/class-citecue-proxy.php | 27 +- includes/class-citecue-seo-head.php | 403 +++++++++++++++----- includes/class-citecue-settings.php | 12 + readme.txt | 9 +- tests/cases/test-seo-head-delivery.php | 129 +++++++ tests/cases/test-seo-head-merge.php | 104 +++++ tests/cases/test-settings.php | 37 ++ tests/cases/test-woocommerce-exclusions.php | 40 ++ 11 files changed, 714 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index c7c4c14..938a2b1 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ cloaking. - **Abuse-bounded.** Cache keys use CiteCue-compatible URL normalization (tracking params, `www.`, trailing slashes deduped), and outbound lookups are capped by a per-minute budget shared across the crawler and llms.txt paths (default 120, filterable) — neither a spoofed crawler UA spraying unique URLs nor a flood on `/llms.txt` can force unbounded API calls. When CiteCue reports a page is no longer optimized, its cached copy is evicted immediately. - **Crawler registry.** A bundled token list ships with the plugin and refreshes daily from the public `GET /api/delivery/v1/crawlers` feed, so newly added crawlers are served without a plugin update. - **Metadata never blocks a render.** The `wp_head` path reads the transient cache and nothing else. A URL with no cached block renders untouched and queues a WP-Cron fetch, so the *next* visitor gets the tags; a stale block keeps being printed while the refresh runs. A human page view never waits on CiteCue. -- **Gap-filling, not overriding.** The injector buffers `wp_head`, reads the slots the rest of it filled (title, canonical, each meta name/property, JSON-LD) and drops any CiteCue tag whose slot is taken — detection by output, so it is correct against SEO plugins and themes it has never heard of. It never emits a second `<title>` or a second canonical. +- **Gap-filling, not overriding.** The injector captures the rendered `<head>` (buffering from `template_redirect` so a theme printing its own `<title>` in `header.php` is seen too, not just `wp_head` output), reads the slots already filled — title, canonical, each meta name/property, JSON-LD — and drops any CiteCue tag whose slot is taken. Detection by output, so it is correct against SEO plugins and themes it has never heard of. It never emits a second `<title>` or a second canonical. +- **Nothing from the response is echoed.** Every tag is parsed, checked against an allowlist of shapes, and *rebuilt* from escaped values: only `application/ld+json` scripts (re-encoded with `JSON_HEX_TAG`), only `canonical`/`alternate` links with an http/https `href`, only `<meta>` carrying a `name`/`property`. Attributes outside that set are discarded. A regex that merely inspects remote markup is one quoted attribute away from authorizing something it misread; rebuilding cannot be. +- **A visitor cannot grow the queue.** The lookup URL is the request's own minus every query argument WordPress does not recognize as a query variable, and scheduling is capped per minute. Without both, `/?x=<random>` — which renders the homepage — would mint unlimited cache keys and cron events. It also keeps order keys, reset tokens and nonces out of what is cached and sent; WooCommerce cart/checkout/account pages are skipped outright, as on the crawler path. - **Verification-compatible.** Served pages carry `X-Citecue: served` and llms.txt carries `X-Citecue: llms-txt` — the headers CiteCue's *Verify installation* button probes for. ## Setup @@ -204,7 +206,9 @@ With WooCommerce active: | `citecue_serve_timeout` | filter | Delivery API timeout on the serving path (default 3 s) | | `citecue_lookup_budget` | filter | Max delivery API lookups per minute across the crawler, llms.txt and metadata-refresh paths combined (default 120); beyond it, requests are answered from cache or passed through | | `citecue_should_inject_seo_head` | filter | Veto SEO head injection for a specific page | -| `citecue_seo_head_tags` | filter | Change the head tags about to be printed, after the gap-fill — the escape hatch for letting CiteCue win a slot your SEO plugin owns | +| `citecue_seo_head_tags` | filter | Change the head tags about to be printed, after the gap-fill — the escape hatch for letting CiteCue win a slot your SEO plugin owns. Output is printed unescaped, so a filter that adds markup owns escaping it | +| `citecue_seo_head_query_vars` | filter | Query variables kept in the metadata lookup URL (defaults to the site's public query vars); anything else is stripped before caching, scheduling or sending | +| `citecue_seo_head_schedule_budget` | filter | Max metadata refreshes queued per minute (default 20) | | `citecue_ingest_postarr` | filter | Adjust the post array before insert/update | | `citecue_ingest_rate_limit` | filter | Ingest requests allowed per hour (default 120) | | `citecue_output_meta_description` | filter | Control the meta-description tag for pushed content | @@ -213,7 +217,7 @@ With WooCommerce active: The delivery API is never called while a human is waiting. For an AI-crawler request or `/llms.txt` the call is on the request path, because only a bot is blocked by it. For enriched metadata it is on WP-Cron: the render path reads one transient, and a miss queues the fetch rather than making it. -A human page view with metadata switched off does no HTTP, no extra database query and no cache lookup — the middleware returns as soon as the User-Agent fails to match, having done nothing but a substring scan over the crawler tokens (which travel in an autoloaded option WordPress has already read). With it switched on, the added cost is one transient read, plus — only when there is something to print — an output buffer over `wp_head` and a regex pass across it. +A human page view with metadata switched off does no HTTP, no extra database query and no cache lookup — the middleware returns as soon as the User-Agent fails to match, having done nothing but a substring scan over the crawler tokens (which travel in an autoloaded option WordPress has already read). With it switched on, the added cost is one transient read, plus — only when there is something to print — an output buffer held from `template_redirect` to the end of `wp_head` (so it closes within the first few kilobytes of the page, not at the end of it) and a parse of the head it captured. For a crawler request the cost is one API call, with a 3 s timeout, and only when the local cache cannot answer: optimized bodies are cached for 24 h and revalidated with an ETag, misses are negative-cached for 60 s, and llms.txt is treated as fresh for 5 minutes. A repeat crawl of a cached page is a 304, not a re-download. diff --git a/includes/class-citecue-cache.php b/includes/class-citecue-cache.php index b9443e1..bb48a73 100644 --- a/includes/class-citecue-cache.php +++ b/includes/class-citecue-cache.php @@ -276,6 +276,36 @@ public function set_seo_head_miss( $url ) { set_transient( 'citecue_shm_' . md5( $this->salt() . '|' . self::normalize_url( $url ) ), 1, self::MISS_TTL ); } + /** + * Consumes one unit of the per-minute budget for QUEUEING a metadata + * refresh, which is a different ceiling from the outbound-call one above + * and needs to be (PR #10 review). + * + * `consume_lookup_budget()` is spent when a job RUNS, so it bounds what + * reaches CiteCue and nothing else. Scheduling happens on the render path, + * where an anonymous visitor decides how many distinct URLs to ask about — + * and every scheduled event is a row in WordPress's serialized `cron` + * option, which is rewritten in full on every change. Unbounded, that turns + * page views into an ever more expensive database write. This caps it. + * + * @return bool Whether a refresh may be queued. + */ + public function consume_seo_head_schedule_budget() { + /** + * Filters the maximum SEO head refreshes queued per minute. + * + * @param int $limit Default 20. + */ + $limit = max( 1, (int) apply_filters( 'citecue_seo_head_schedule_budget', 20 ) ); + $key = 'citecue_shb_' . (int) floor( time() / MINUTE_IN_SECONDS ); + $count = (int) get_transient( $key ); + if ( $count >= $limit ) { + return false; + } + set_transient( $key, $count + 1, 2 * MINUTE_IN_SECONDS ); + return true; + } + /** * Cached llms.txt, or null. * diff --git a/includes/class-citecue-plugin.php b/includes/class-citecue-plugin.php index 10ce133..f660d3b 100644 --- a/includes/class-citecue-plugin.php +++ b/includes/class-citecue-plugin.php @@ -127,6 +127,38 @@ public static function current_url() { return esc_url_raw( $scheme . '://' . $host . $uri ); } + /** + * WooCommerce requests neither delivery path may touch: cart, checkout + * (incl. order-pay / order-received), account pages and every other WC + * endpoint are session/transactional; `?add-to-cart=` GETs mutate the cart + * and `wc-ajax` calls are API traffic. Product and shop-archive pages + * remain eligible — those are the highest-value pages to optimize. + * + * Shared with the SEO head injector rather than owned by the proxy + * (PR #10 review). The proxy's reason for skipping these is that they are + * session content; the injector has a second, sharper one — their URLs + * carry order ids, `wc_order_*` keys and account tokens, and that path + * would put the URL in a cron argument and then send it to CiteCue. One + * copy means the two can never disagree about which those pages are. + * + * @return bool True when this request belongs to WooCommerce. + */ + public static function is_woocommerce_request() { + if ( ! class_exists( 'WooCommerce' ) ) { + return false; + } + if ( function_exists( 'is_cart' ) && ( is_cart() || is_checkout() || is_account_page() ) ) { + return true; + } + if ( function_exists( 'is_wc_endpoint_url' ) && is_wc_endpoint_url() ) { + return true; + } + if ( isset( $_GET['wc-ajax'] ) || isset( $_GET['add-to-cart'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request classification. + return true; + } + return false; + } + /** * Init: cron self-heal. * diff --git a/includes/class-citecue-proxy.php b/includes/class-citecue-proxy.php index 6bddd42..96b6a91 100644 --- a/includes/class-citecue-proxy.php +++ b/includes/class-citecue-proxy.php @@ -250,7 +250,7 @@ private function is_eligible_request() { if ( '' !== (string) get_query_var( 'sitemap' ) ) { return false; } - if ( $this->is_excluded_woocommerce_request() ) { + if ( Citecue_Plugin::is_woocommerce_request() ) { return false; } if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) { @@ -271,31 +271,6 @@ private function is_eligible_request() { return true; } - /** - * WooCommerce requests the proxy must never touch: cart, checkout (incl. - * order-pay / order-received), account pages and every other WC endpoint - * are session/transactional; `?add-to-cart=` GETs mutate the cart and - * `wc-ajax` calls are API traffic. Product and shop-archive pages remain - * eligible — those are the highest-value pages to serve optimized. - * - * @return bool True when this request belongs to WooCommerce. - */ - private function is_excluded_woocommerce_request() { - if ( ! class_exists( 'WooCommerce' ) ) { - return false; - } - if ( function_exists( 'is_cart' ) && ( is_cart() || is_checkout() || is_account_page() ) ) { - return true; - } - if ( function_exists( 'is_wc_endpoint_url' ) && is_wc_endpoint_url() ) { - return true; - } - if ( isset( $_GET['wc-ajax'] ) || isset( $_GET['add-to-cart'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only request classification. - return true; - } - return false; - } - /** * Emits the optimized document and ends the request. Stamps the * `X-Citecue: served` header CiteCue's install verifier looks for, and diff --git a/includes/class-citecue-seo-head.php b/includes/class-citecue-seo-head.php index 61ec61f..89c7774 100644 --- a/includes/class-citecue-seo-head.php +++ b/includes/class-citecue-seo-head.php @@ -12,17 +12,17 @@ * content-parity and additive, so adding it is not cloaking, whereas serving a * rewritten document to a human would be. * - * Two rules govern everything below. + * Four rules govern everything below. * * **Never emit a duplicate.** WordPress core prints `<title>` and * `<link rel="canonical">` on its own, and Yoast, Rank Math, AIOSEO, SEOPress, * The SEO Framework, Slim SEO and Jetpack each print some combination of * title, description, OpenGraph and JSON-LD. A second `<title>` is invalid * HTML and a second canonical makes Google pick one arbitrarily — so this - * fills gaps only: it captures what the rest of `wp_head` actually printed and - * drops every CiteCue tag whose slot is already taken. Detecting emitted - * markup rather than sniffing for `WPSEO_VERSION` is what makes that correct - * against SEO plugins and themes nobody here has heard of. + * fills gaps only: it captures the rendered `<head>` and drops every CiteCue + * tag whose slot is already taken. Detecting emitted markup rather than + * sniffing for `WPSEO_VERSION` is what makes that correct against SEO plugins + * and themes nobody here has heard of. * * **Never block a human.** The proxy may spend a request budget on an outbound * call because only a bot is waiting. Here a real visitor is, so the render @@ -30,6 +30,18 @@ * schedules a background refresh, so the next visitor gets the block. Cold * pages cost one un-enriched view, never one slow one. * + * **Never print markup we did not build.** The block is trusted content for + * this site's own head, but it arrives over the network and lands in a human's + * browser. Nothing from the response is echoed: every tag is parsed, checked + * against an allowlist of shapes, and rebuilt from escaped values. A regex + * that merely *inspects* remote markup is one quoted attribute away from + * authorizing something it misread (PR #10 review) — rebuilding cannot be. + * + * **Never let a visitor grow the queue.** The URL asked about is the request's + * own, minus every query argument WordPress does not recognize, and scheduling + * is capped per minute. Without both, `/?x=<random>` — which resolves to the + * homepage — would mint an unbounded number of cache keys and cron events. + * * @package Citecue */ @@ -52,24 +64,30 @@ class Citecue_Seo_Head { const REFRESH_LOCK_TTL = MINUTE_IN_SECONDS; /** - * `wp_head` priority the output capture opens at. Below every priority in - * practical use — core's `_wp_render_title_tag` is 1, `rel_canonical` 10, - * and the SEO plugins cluster around 1 — so the capture sees all of it. + * `template_redirect` priority the capture opens at. After the crawler + * proxy and the llms.txt handler, which own priority 0 and both `exit`, so + * a request either of them serves never opens a buffer here. */ - const CAPTURE_START_PRIORITY = -PHP_INT_MAX; + const CAPTURE_START_PRIORITY = 1; /** `wp_head` priority the capture closes and injects at: after everyone. */ const CAPTURE_END_PRIORITY = PHP_INT_MAX; /** - * `<link>` relations that may be injected. Anything else in the block is - * dropped: the response is trusted markup for THIS site's head, but it - * reaches a human's browser, so the set of things it may add is the small - * set it needs (`stylesheet`, `preload` and friends have no business - * arriving from a metadata endpoint). + * `<link>` relations that may be injected, and `<meta>` values are escaped + * into a tag we build ourselves. Anything else in the block is dropped: + * `stylesheet`, `preload` and friends have no business arriving from a + * metadata endpoint. */ const ALLOWED_LINK_RELS = array( 'canonical', 'alternate' ); + /** + * Query arguments kept when there is no `wp` object to ask. Only the + * built-ins that actually select content on a plain-permalink install — + * enough that such a site still gets its pages enriched. + */ + const FALLBACK_QUERY_VARS = array( 'p', 'page_id', 'cat', 'tag', 'name', 'pagename', 'post_type', 'paged', 'page', 'author_name', 'category_name', 'year', 'monthnum', 'day' ); + /** * Plugin container. * @@ -110,14 +128,22 @@ public function __construct( Citecue_Plugin $plugin ) { */ public function register() { add_action( self::REFRESH_HOOK, array( $this, 'refresh' ), 10, 1 ); - add_action( 'wp_head', array( $this, 'start_capture' ), self::CAPTURE_START_PRIORITY ); + add_action( 'template_redirect', array( $this, 'start_capture' ), self::CAPTURE_START_PRIORITY ); add_action( 'wp_head', array( $this, 'finish_capture' ), self::CAPTURE_END_PRIORITY ); } /** * Opens the capture, but only when there is something to inject — buffering * a page we will not touch is pure overhead, and every reason not to inject - * is knowable before the first byte of `wp_head`. + * is knowable before the theme renders a byte. + * + * Opened at `template_redirect` rather than at the start of `wp_head` + * (PR #10 review): a theme that prints `<title>`, a canonical or its own + * OpenGraph directly in `header.php` does so BEFORE `wp_head` runs, so a + * capture scoped to the action would read those slots as empty and append + * the duplicate the gap-fill exists to prevent. This is still not a + * whole-page buffer — `wp_head` sits in `<head>`, so it closes within the + * first few kilobytes. * * @return void */ @@ -134,8 +160,8 @@ public function start_capture() { } /** - * Closes the capture, re-emits everything the rest of `wp_head` printed, - * and appends the CiteCue tags that found an empty slot. + * Closes the capture, re-emits everything rendered so far, and appends the + * CiteCue tags that found an empty slot. * * @return void */ @@ -149,23 +175,23 @@ public function finish_capture() { return; } - // Someone else's buffer is still open on top of ours (or ours was - // closed for us). Either way the levels no longer line up, so take the - // conservative exit: flush whatever we own so no output is lost or - // reordered, and inject nothing this request. A missing block is a - // non-event; mangled `<head>` output is not. + // Our buffer is no longer the top one: something opened another inside + // the head and has not closed it, or closed ours for us. Leave every + // buffer exactly as it is and inject nothing (PR #10 review). Unwinding + // down to ours would close a buffer this class did not create, and its + // owner's later ob_get_clean() would then take an unrelated one — + // breaking whatever minifier or cache opened it. Ours flushes with the + // rest at the end of the request, so no output is lost or reordered; + // only the tags are skipped, which is a non-event. if ( ob_get_level() !== $level ) { - while ( ob_get_level() >= $level && ob_get_level() > 0 ) { - ob_end_flush(); - } return; } $head = (string) ob_get_clean(); $tags = self::merge( $head, $decision['block'] ); - // Everything the rest of wp_head printed, verbatim — this is other - // plugins' and core's own output passing straight back through. + // Everything rendered so far, verbatim — the theme's own markup and + // other plugins' `wp_head` output passing straight back through. echo $head; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped if ( ! $tags ) { @@ -173,9 +199,8 @@ public function finish_capture() { } echo "\n<!-- CiteCue -->\n"; - // Head-only markup generated by CiteCue for this site's own page, and - // narrowed to an allowlist of tag shapes by self::slot_for() — output - // verbatim by design (escaping would destroy the markup). + // Built by self::rebuild_tag() out of escaped values — never a string + // from the response — so this is our own markup, not remote markup. echo implode( "\n", $tags ) . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } @@ -195,7 +220,7 @@ public function decide() { return self::skip( 'not-eligible' ); } - $url = Citecue_Plugin::current_url(); + $url = self::lookup_url(); if ( '' === $url ) { return self::skip( 'no-url' ); } @@ -204,7 +229,7 @@ public function decide() { * Filters whether to inject CiteCue's SEO head into this page. * * @param bool $should_inject Default true. - * @param string $url Absolute request URL. + * @param string $url URL the block is looked up by. */ if ( ! apply_filters( 'citecue_should_inject_seo_head', true, $url ) ) { return self::skip( 'vetoed' ); @@ -302,6 +327,76 @@ public function refresh( $url ) { } } + /** + * The URL this request's block is looked up by: the request's own, minus + * every query argument WordPress does not recognize as a query variable. + * + * Dropping the rest is not tidiness (PR #10 review). `/?x=<random>` still + * resolves to the homepage, so keeping the raw query string would let an + * anonymous visitor mint unlimited distinct cache keys and cron arguments + * for one page; and a query string can carry things that must never be + * sent to a third party — a password-reset token, a WooCommerce order key, + * a nonce. What survives is the set that actually selects content, which is + * exactly what CiteCue could have an enriched page for. + * + * @return string + */ + public static function lookup_url() { + $url = Citecue_Plugin::current_url(); + if ( '' === $url ) { + return ''; + } + + $query = (string) wp_parse_url( $url, PHP_URL_QUERY ); + if ( '' === $query ) { + return $url; + } + + $pairs = array(); + parse_str( $query, $pairs ); + + $allowed = self::allowed_query_vars(); + $drop = array(); + foreach ( array_keys( $pairs ) as $key ) { + if ( ! in_array( (string) $key, $allowed, true ) ) { + $drop[] = (string) $key; + } + } + + return $drop ? (string) remove_query_arg( $drop, $url ) : $url; + } + + /** + * Query variable names that may survive into the lookup URL. + * + * Taken from the live `WP` object, so a custom post type or a plugin that + * registers its own public query variable keeps working without this + * needing to know about it. Falls back to the built-ins when there is no + * `WP` object (WP-Cron, WP-CLI, a unit test calling this directly). + * + * @return string[] + */ + private static function allowed_query_vars() { + $wp = isset( $GLOBALS['wp'] ) ? $GLOBALS['wp'] : null; + + $vars = ( $wp instanceof WP && ! empty( $wp->public_query_vars ) ) + ? $wp->public_query_vars + : self::FALLBACK_QUERY_VARS; + + /** + * Filters the query variables kept in the SEO head lookup URL. + * + * Anything not listed is stripped before the URL is cached, scheduled + * or sent to CiteCue. Add to it only for a variable that genuinely + * selects different content, and never for one that carries a token. + * + * @param string[] $vars Allowed query variable names. + */ + $vars = apply_filters( 'citecue_seo_head_query_vars', $vars ); + + return is_array( $vars ) ? array_map( 'strval', $vars ) : array(); + } + /** * The CiteCue tags that may be added to a head that already contains * `$existing`: every tag whose slot — the title, the canonical, one meta @@ -311,23 +406,27 @@ public function refresh( $url ) { * with every other SEO plugin on the site, and it is worth being able to * test it against a real Yoast head dump without a request in sight. * - * @param string $existing Markup the rest of wp_head printed. + * @param string $existing Rendered head markup. * @param string $block CiteCue's head block. - * @return string[] Tags to append, in the order CiteCue sent them. + * @return string[] Rebuilt tags to append, in the order CiteCue sent them. */ public static function merge( $existing, $block ) { $occupied = self::slots_in( $existing ); $tags = array(); foreach ( self::tags_in( $block ) as $tag ) { - $slot = self::slot_for( $tag ); - if ( '' === $slot || isset( $occupied[ $slot ] ) ) { + $rebuilt = self::rebuild_tag( $tag ); + if ( null === $rebuilt ) { + continue; + } + list( $slot, $html ) = $rebuilt; + if ( isset( $occupied[ $slot ] ) ) { continue; } // Claim it here too, so a block that somehow carries two canonicals // cannot contribute both. $occupied[ $slot ] = true; - $tags[] = $tag; + $tags[] = $html; } /** @@ -335,11 +434,13 @@ public static function merge( $existing, $block ) { * * The default policy is gap-filling: a tag whose slot another plugin * has already filled is dropped. Use this to re-add one (having removed - * the other plugin's copy yourself) or to drop more. + * the other plugin's copy yourself) or to drop more. Whatever is + * returned is printed unescaped, so a filter that adds markup owns + * escaping it. * * @param string[] $tags Tags that survived the gap-fill. * @param string $block The full block CiteCue returned. - * @param string $existing Markup the rest of wp_head printed. + * @param string $existing Rendered head markup. */ $tags = apply_filters( 'citecue_seo_head_tags', $tags, $block, $existing ); @@ -358,7 +459,7 @@ private static function tags_in( $html ) { } /** - * The slots occupied by existing head markup. + * The slots occupied by markup already in the head. * * @param string $html Head markup. * @return array<string,bool> @@ -366,7 +467,7 @@ private static function tags_in( $html ) { private static function slots_in( $html ) { $slots = array(); foreach ( self::tags_in( $html ) as $tag ) { - $slot = self::slot_for( $tag, true ); + $slot = self::slot_for( $tag ); if ( '' !== $slot ) { $slots[ $slot ] = true; } @@ -375,86 +476,195 @@ private static function slots_in( $html ) { } /** - * The slot one element claims, or '' for an element that claims none. + * The slot one existing element claims, or '' for one that claims none. * - * Reading the same element two ways on purpose. Scanning what other plugins - * printed ($lenient) only asks "is this slot taken", so any `<link>` rel and - * any `<script>` type answers for itself. Deciding what CiteCue may PRINT - * applies the allowlists: only `application/ld+json` scripts, only the link - * relations in ALLOWED_LINK_RELS, and only meta tags that identify - * themselves with a name or property. Everything else returns '' and is - * dropped — the endpoint has never sent anything else, and the tags land in - * a human's browser, so "recognized shapes only" is the right posture for - * markup arriving over the network. + * Only asks "is this slot taken", so any `<link>` relation counts and any + * `<script>` that is not structured data counts for nothing — a site with + * an analytics snippet in its head must not lose CiteCue's schema to it. + * Deciding what may be PRINTED is a different and much stricter question, + * answered by {@see self::rebuild_tag()}. * - * @param string $tag One element. - * @param bool $lenient Whether to classify rather than authorize. + * @param string $tag One element. * @return string */ - private static function slot_for( $tag, $lenient = false ) { + private static function slot_for( $tag ) { if ( preg_match( '#^<title\b#i', $tag ) ) { return 'title'; } - // A plain `<script>` occupies nothing and may never be printed: only - // structured data collides with structured data, and a metadata - // endpoint has no business shipping executable code either way. The one - // rule covers both readings. + $attributes = self::attributes( $tag ); + + if ( preg_match( '#^<script\b#i', $tag ) ) { + $type = isset( $attributes['type'] ) ? strtolower( $attributes['type'] ) : ''; + return 'application/ld+json' === $type ? 'jsonld' : ''; + } + + if ( preg_match( '#^<link\b#i', $tag ) ) { + $rel = isset( $attributes['rel'] ) ? strtolower( trim( $attributes['rel'] ) ) : ''; + return '' !== $rel ? 'link:' . $rel : ''; + } + + if ( preg_match( '#^<meta\b#i', $tag ) ) { + $key = ''; + if ( isset( $attributes['property'] ) && '' !== $attributes['property'] ) { + $key = $attributes['property']; + } elseif ( isset( $attributes['name'] ) ) { + $key = $attributes['name']; + } + $key = trim( $key ); + return '' !== $key ? 'meta:' . strtolower( $key ) : ''; + } + + return ''; + } + + /** + * One CiteCue tag, rebuilt from parsed and escaped values, as + * `array(slot, html)` — or null when it is not a shape we print. + * + * Rebuilding rather than passing the response's own markup through is the + * point (PR #10 review). Inspecting remote markup with a regex and then + * echoing it means one misread attribute is an executable tag on a real + * visitor's page: `<link foo="a rel=canonical" rel="stylesheet" + * onload="…">` reads as a canonical to a pattern searching for `rel=`, and + * as a stylesheet with an event handler to the browser. Nothing survives + * this function that it did not itself write, so there is no such gap to + * find — every attribute other than the ones named below is discarded, and + * `href` is limited to http/https. + * + * @param string $tag One element from CiteCue's block. + * @return array{0:string,1:string}|null + */ + private static function rebuild_tag( $tag ) { + if ( preg_match( '#^<title\b[^>]*>(.*)$#is', $tag, $match ) ) { + $text = self::text( $match[1] ); + return '' === $text ? null : array( 'title', '' . esc_html( $text ) . '' ); + } + + $attributes = self::attributes( $tag ); + if ( preg_match( '#^]*>(.*)$#is', $tag, $match ) ) { + return null; + } + $data = json_decode( trim( $match[1] ), true ); + if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) { + return null; + } + // JSON_HEX_TAG is what makes this safe inside a script element: it + // renders every angle bracket as a < / > escape, so a + // closing script tag buried in a string value cannot end the + // element early. Re-encoding from the decoded data, rather than + // passing the original text through, is what guarantees there is + // nothing else in there either. + $json = wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE ); + return ( false === $json || '' === $json ) + ? null + : array( 'jsonld', '' ); } if ( preg_match( '#^' ); } if ( preg_match( '#^'; + return array( 'meta:' . strtolower( $key ), $html ); } - return ''; + return null; } /** - * One attribute's value, or '' when absent. + * An attribute value or element body as plain text, ready to be escaped + * back out. Decoded first: the value arrives HTML-encoded, so escaping it + * as-is would turn `&` into `&amp;` on the page. * - * @param string $tag Element markup. - * @param string $name Attribute name. + * @param string $raw Encoded value. * @return string */ - private static function attribute( $tag, $name ) { - $pattern = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>]+))#i'; - if ( ! preg_match( $pattern, $tag, $match ) ) { - return ''; + private static function text( $raw ) { + return trim( html_entity_decode( wp_strip_all_tags( (string) $raw ), ENT_QUOTES, 'UTF-8' ) ); + } + + /** + * An element's attributes, lowercased names to unquoted values. + * + * Walks name/value pairs left to right rather than searching for one + * attribute at a time, which is the difference between reading markup and + * guessing at it: a search for `rel=` finds it inside `foo="a rel=x"`, + * whereas a scan that consumes each quoted value as part of its own pair + * cannot. First occurrence wins, matching how browsers resolve a repeated + * attribute. + * + * @param string $tag One element. + * @return array + */ + private static function attributes( $tag ) { + $out = array(); + + if ( ! preg_match( '#^<[a-zA-Z][a-zA-Z0-9]*([^>]*)>#', (string) $tag, $match ) ) { + return $out; + } + + $pattern = '#([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*(?:=\s*("[^"]*"|\'[^\']*\'|[^\s"\'>]+))?#'; + if ( ! preg_match_all( $pattern, $match[1], $found, PREG_SET_ORDER ) ) { + return $out; } - foreach ( array( 1, 2, 3 ) as $group ) { - if ( isset( $match[ $group ] ) && '' !== $match[ $group ] ) { - return trim( $match[ $group ] ); + + foreach ( $found as $pair ) { + $name = strtolower( $pair[1] ); + if ( isset( $out[ $name ] ) ) { + continue; + } + $value = isset( $pair[2] ) ? $pair[2] : ''; + if ( '' !== $value && ( '"' === $value[0] || "'" === $value[0] ) ) { + $value = substr( $value, 1, -1 ); } + $out[ $name ] = $value; } - return ''; + + return $out; } /** - * Queues one background fetch for a URL, at most once a minute however many - * visitors arrive in the meantime. + * Queues one background fetch for a URL, at most once a minute per URL and + * within a site-wide per-minute ceiling. * - * @param string $url Absolute page URL. + * The per-URL lock alone does not bound anything (PR #10 review): distinct + * URLs take distinct locks, and the outbound lookup budget is only spent + * when a job RUNS, so without a ceiling here an anonymous visitor could + * push unlimited events into WordPress's serialized cron option and make + * every subsequent write more expensive. `lookup_url()` removes most of the + * ways to mint a distinct URL; this bounds what is left. + * + * @param string $url Lookup URL. * @return void */ private function schedule_refresh( $url ) { @@ -462,6 +672,11 @@ private function schedule_refresh( $url ) { if ( get_transient( $lock ) ) { return; } + + if ( ! $this->plugin->cache->consume_seo_head_schedule_budget() ) { + return; + } + set_transient( $lock, 1, self::REFRESH_LOCK_TTL ); wp_schedule_single_event( time(), self::REFRESH_HOOK, array( $url ) ); @@ -496,6 +711,18 @@ private function is_eligible_request() { if ( function_exists( 'is_favicon' ) && is_favicon() ) { return false; } + // The same store pages the crawler proxy refuses to touch, for a reason + // that applies twice over here (PR #10 review): those URLs carry order + // ids, `wc_order_*` keys and account tokens, and this path would put + // the URL in a cron argument and then send it to CiteCue. + if ( Citecue_Plugin::is_woocommerce_request() ) { + return false; + } + // Content behind a password is not content CiteCue has, and its + // metadata should not describe what a visitor cannot read. + if ( is_singular() && post_password_required() ) { + return false; + } return true; } diff --git a/includes/class-citecue-settings.php b/includes/class-citecue-settings.php index 7736770..1061071 100644 --- a/includes/class-citecue-settings.php +++ b/includes/class-citecue-settings.php @@ -317,6 +317,18 @@ public function sanitize( $input ) { delete_transient( 'citecue_circuit' ); } + // A changed project invalidates every cached body, llms.txt and head + // block on the site (PR #10 review). All three key off the cache salt + // and the URL and NOT off the project, so without this an administrator + // who repoints the site at another CiteCue project keeps being served + // the previous project's content under the new one's name — its + // optimized pages to crawlers, and its title, canonical and structured + // data onto live pages, for up to a day. Disconnecting already flushes; + // switching project is the same event by another route. + if ( $out['public_key'] !== $current['public_key'] ) { + ( new Citecue_Cache() )->flush(); + } + $this->values = $out; return $out; } diff --git a/readme.txt b/readme.txt index 7aefa1a..496f2d9 100644 --- a/readme.txt +++ b/readme.txt @@ -46,7 +46,9 @@ The service is reached at `https://app.citecue.com` (or the origin you pin with **Serving a page to an AI crawler** — on each request from a matched AI crawler, and never for a human visitor or a logged-in user. The plugin sends the requested URL, the matched crawler's User-Agent token and the site's project key to `/api/delivery/v2/page`. No visitor data — no IP address, no cookies, no personal data — is sent. CiteCue records the crawler hit so it can report it back to you. Responses are cached, misses are remembered for a minute, and a per-minute budget caps the total. -**Enriching a page's metadata** — in the background, on WP-Cron, for a URL a visitor has requested while enriched metadata is switched on. The requested URL and the site's project key are sent to `/api/delivery/v2/seo-head`. No visitor data — no IP address, no cookies, no personal data — is sent, and this never happens while a visitor is waiting: a page with no cached block yet is rendered untouched and the fetch is queued for afterwards. Responses are cached, empty answers are remembered for a minute, and the same per-minute budget caps the total. +**Enriching a page's metadata** — in the background, on WP-Cron, for a URL a visitor has requested while enriched metadata is switched on. The requested URL and the site's project key are sent to `/api/delivery/v2/seo-head`. No visitor data — no IP address, no cookies, no personal data — is sent, and this never happens while a visitor is waiting: a page with no cached block yet is rendered untouched and the fetch is queued for afterwards. + +Before a URL is cached, queued or sent, every query argument WordPress does not recognise as a query variable is removed from it, so tokens, order keys and nonces that happen to be in the address are never included. WooCommerce cart, checkout, account and order pages are skipped entirely. Responses are cached, empty answers are remembered for a minute, the same per-minute budget caps outbound calls, and a second per-minute cap limits how many refreshes a burst of traffic can queue. **Serving llms.txt** — when `/llms.txt` is requested and the feature is on. The site's project key is sent to `/api/delivery/v2/llms.txt`. The response is cached. @@ -119,7 +121,10 @@ The plugin folder is now citecue-ai-auto-fix. If you installed 1.0.0 by uploadin * Fills gaps only: anything WordPress, your theme or your SEO plugin already prints is left untouched, so the plugin never emits a second title or canonical. Detection reads the real `` output rather than looking for particular plugins. * Never delays a page: the render path reads cache only. A URL with nothing cached yet renders untouched and the fetch is queued for afterwards. Cached blocks survive a CiteCue outage and are served while a refresh runs. * The connection now tells CiteCue whether this site injects metadata, so CiteCue stops reporting fixes as reaching human visitors when nothing puts them on the page. Sites connected before this release are asked to reconnect once. -* New filters: `citecue_should_inject_seo_head` (skip a page) and `citecue_seo_head_tags` (change what is printed). +* Nothing from the response is printed as it arrived: every tag is parsed, checked against an allowlist of shapes and rebuilt from escaped values, with structured data re-encoded so it cannot escape its own script element. +* Query arguments WordPress does not recognise are stripped from the URL before it is cached, queued or sent, and store pages are skipped — so order keys, reset tokens and nonces are never included, and a visitor cannot fill the scheduler with unique addresses for one page. +* Changing the selected CiteCue project now clears the delivery cache, instead of serving the previous project's pages, llms.txt and metadata under the new one for up to a day. +* New filters: `citecue_should_inject_seo_head` (skip a page), `citecue_seo_head_tags` (change what is printed), `citecue_seo_head_query_vars` and `citecue_seo_head_schedule_budget`. = 1.0.3 = * No functional change. Annotates the DONOTCACHEPAGE definitions so code-quality tooling stops reporting a naming-convention violation the constant cannot avoid — page caches look for that exact name. diff --git a/tests/cases/test-seo-head-delivery.php b/tests/cases/test-seo-head-delivery.php index cf19a93..13d0533 100644 --- a/tests/cases/test-seo-head-delivery.php +++ b/tests/cases/test-seo-head-delivery.php @@ -175,6 +175,69 @@ public function test_filter_can_veto_a_page() { $this->assertSame( 'vetoed', $this->seo_head()->decide()['reason'] ); } + /** + * `/?x=` still renders the homepage, so a raw request URL would let + * an anonymous visitor mint unlimited cache keys and cron arguments for one + * page. Unrecognized query arguments are stripped before any of that. + * + * @return void + */ + public function test_unknown_query_arguments_never_reach_the_lookup_url() { + $this->configure_delivery(); + $this->fake_visitor_request( '/?x=abcdef&utm_source=news' ); + + $url = Citecue_Seo_Head::lookup_url(); + + $this->assertStringNotContainsString( 'x=abcdef', $url ); + $this->assertStringNotContainsString( 'utm_source', $url ); + } + + /** + * Stripping must not break a plain-permalink site, where the query string + * is how a page is addressed at all. + * + * @return void + */ + public function test_recognized_query_arguments_survive() { + $this->configure_delivery(); + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $this->fake_visitor_request( '/?p=' . $post_id . '&sessiontoken=secret' ); + + $url = Citecue_Seo_Head::lookup_url(); + + $this->assertStringContainsString( 'p=' . $post_id, $url ); + $this->assertStringNotContainsString( 'sessiontoken', $url ); + } + + /** + * A burst of unique URLs cannot push unlimited events into WordPress's + * serialized cron option: the per-URL lock does not bound distinct URLs, + * and the outbound budget is only spent when a job runs. + * + * @return void + */ + public function test_scheduling_is_capped_per_minute() { + $this->configure_delivery(); + add_filter( 'citecue_seo_head_schedule_budget', static fn() => 3 ); + + // Real posts, each a distinct eligible URL: a nonexistent id is a 404, + // which the injector declines before it ever reaches scheduling. + $injector = $this->seo_head(); + foreach ( self::factory()->post->create_many( 8, array( 'post_status' => 'publish' ) ) as $post_id ) { + $this->fake_visitor_request( '/?p=' . $post_id ); + $injector->decide(); + } + + $jobs = 0; + foreach ( (array) _get_cron_array() as $events ) { + if ( isset( $events[ Citecue_Seo_Head::REFRESH_HOOK ] ) ) { + $jobs += count( $events[ Citecue_Seo_Head::REFRESH_HOOK ] ); + } + } + + $this->assertSame( 3, $jobs ); + } + /** * The background worker stores what CiteCue returns. * @@ -328,6 +391,72 @@ public function test_capture_appends_only_the_gaps() { $this->assertSame( 1, substr_count( $output, '` in header.php does so before + * `wp_head` runs. The capture opens at `template_redirect` precisely so + * that markup is still seen — scoped to the action, this would append a + * second title. + * + * @return void + */ + public function test_theme_markup_printed_before_wp_head_still_claims_its_slot() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( + $url, + 'CiteCue title' + . '' + ); + + $injector = $this->seo_head(); + + ob_start(); + $injector->start_capture(); + echo 'Printed by header.php'; + $injector->finish_capture(); + $output = ob_get_clean(); + + $this->assertSame( 1, substr_count( $output, 'assertStringNotContainsString( 'CiteCue title', $output ); + $this->assertStringContainsString( 'og:title', $output ); + } + + /** + * Another plugin's buffer left open across the end of wp_head must be left + * exactly where it is. Unwinding to reach ours would close a buffer this + * class did not create, and its owner's later ob_get_clean() would then + * take an unrelated one. + * + * @return void + */ + public function test_a_foreign_buffer_is_never_unwound() { + $this->configure_delivery(); + $url = $this->fake_visitor_request(); + $this->plugin->cache->set_seo_head( $url, self::BLOCK ); + + $injector = $this->seo_head(); + + ob_start(); + $injector->start_capture(); + echo 'Theme'; + + // Somebody else opens one and does not close it before wp_head ends. + ob_start(); + $foreign_level = ob_get_level(); + echo 'foreign'; + + $injector->finish_capture(); + + $this->assertSame( $foreign_level, ob_get_level(), 'finish_capture() must not close a buffer it did not open.' ); + $this->assertSame( 'foreign', ob_get_clean() ); + + $output = ob_get_clean(); + $this->assertSame( 'Theme', $output ); + $this->assertStringNotContainsString( 'og:title', $output ); + + ob_end_clean(); + } + /** * Nothing to inject means nothing touched: no buffer, no marker comment, * and every byte of the head exactly where the theme put it. diff --git a/tests/cases/test-seo-head-merge.php b/tests/cases/test-seo-head-merge.php index 1c8e410..9720128 100644 --- a/tests/cases/test-seo-head-merge.php +++ b/tests/cases/test-seo-head-merge.php @@ -175,6 +175,110 @@ public function test_unexpected_markup_in_the_block_is_dropped() { $this->assertStringContainsString( 'og:title', $tags[0] ); } + /** + * The reason nothing from the response is echoed: a pattern that merely + * *searches* for `rel=` finds it inside another attribute's quoted value, + * and would authorize a stylesheet with an event handler as a canonical. + * Rebuilding from parsed attributes has no such gap. + * + * @return void + */ + public function test_a_rel_hidden_in_another_attribute_is_not_read_as_canonical() { + $block = '' + . ''; + + $this->assertSame( array(), Citecue_Seo_Head::merge( '', $block ) ); + } + + /** + * Even on a tag that legitimately claims a slot, only the attributes this + * plugin writes come out the other side. + * + * @return void + */ + public function test_extra_attributes_never_survive_onto_the_page() { + $block = '' + . ''; + + $all = implode( "\n", Citecue_Seo_Head::merge( '', $block ) ); + + $this->assertStringNotContainsString( 'onload', $all ); + $this->assertStringNotContainsString( 'onmouseover', $all ); + $this->assertStringNotContainsString( 'integrity', $all ); + $this->assertStringContainsString( 'https://example.org/', $all ); + $this->assertStringContainsString( 'content="Acme"', $all ); + } + + /** + * A canonical is a URL on the web, not a scheme that executes. + * + * @return void + */ + public function test_non_http_hrefs_are_dropped() { + $block = ''; + + $this->assertSame( array(), Citecue_Seo_Head::merge( '', $block ) ); + } + + /** + * A literal closing script tag inside the JSON ends the element where it + * appears, leaving JSON that no longer decodes — so the whole thing is + * dropped, and the trailing script never becomes a tag of its own either. + * + * @return void + */ + public function test_a_script_smuggled_into_jsonld_yields_nothing() { + $block = '"}' + . ''; + + $this->assertSame( array(), Citecue_Seo_Head::merge( '', $block ) ); + } + + /** + * JSON-LD is re-encoded from the decoded data with JSON_HEX_TAG, so an + * angle bracket in a legitimate string value leaves as an escape rather + * than as something a browser could act on. + * + * @return void + */ + public function test_jsonld_angle_brackets_leave_escaped() { + $block = ''; + + $tags = Citecue_Seo_Head::merge( '', $block ); + + $this->assertCount( 1, $tags ); + $this->assertStringNotContainsString( 'A < B', $tags[0] ); + $this->assertStringContainsString( '<', $tags[0] ); + $this->assertSame( 1, substr_count( strtolower( $tags[0] ), '' ) ); + $this->assertStringContainsString( 'Organization', $tags[0] ); + } + + /** + * Malformed JSON is not structured data, and printing it would put an + * invalid block on the page for a search engine to choke on. + * + * @return void + */ + public function test_unparseable_jsonld_is_dropped() { + $this->assertSame( array(), Citecue_Seo_Head::merge( '', '' ) ); + } + + /** + * Values arrive HTML-encoded, so they must be decoded before being escaped + * back out — otherwise an ampersand gains a new `amp;` on every pass. + * + * @return void + */ + public function test_encoded_values_are_not_double_escaped() { + $block = 'Bar & Grill'; + + $tags = Citecue_Seo_Head::merge( '', $block ); + + $this->assertStringContainsString( 'Bar & Grill', $tags[0] ); + $this->assertStringNotContainsString( '&amp;', $tags[0] ); + } + /** * A block that somehow carries the same slot twice contributes it once. * diff --git a/tests/cases/test-settings.php b/tests/cases/test-settings.php index 4e58383..b0b5d37 100644 --- a/tests/cases/test-settings.php +++ b/tests/cases/test-settings.php @@ -177,6 +177,43 @@ public function test_an_unchanged_api_key_leaves_the_backoff_alone() { $this->assertNotFalse( get_option( 'citecue_auth_failed' ) ); } + /** + * Cached bodies, llms.txt and head blocks all key off the cache salt and + * the URL, and NOT off the project — so repointing the site at another + * CiteCue project would keep serving the previous project's content under + * the new one's name, onto live pages, for up to a day. + * + * @return void + */ + public function test_changing_the_project_flushes_every_cache() { + $url = home_url( '/hello-world/' ); + $this->settings->update( array( 'public_key' => 'pk_old' ) ); + $this->plugin->cache->set_page( $url, 'old project', '"v1"', 'enriched' ); + $this->plugin->cache->set_seo_head( $url, 'old project' ); + $this->plugin->cache->set_llms_txt( '# old project', '"v1"' ); + + $this->settings->sanitize( array( 'public_key' => 'pk_new' ) ); + + $this->assertNull( $this->plugin->cache->get_page( $url ) ); + $this->assertNull( $this->plugin->cache->get_seo_head( $url ) ); + $this->assertNull( $this->plugin->cache->get_llms_txt() ); + } + + /** + * Re-saving the same project must not throw the site's whole cache away. + * + * @return void + */ + public function test_an_unchanged_project_keeps_the_cache() { + $url = home_url( '/hello-world/' ); + $this->settings->update( array( 'public_key' => 'pk_same' ) ); + $this->plugin->cache->set_seo_head( $url, 'still valid' ); + + $this->settings->sanitize( array( 'public_key' => 'pk_same' ) ); + + $this->assertNotNull( $this->plugin->cache->get_seo_head( $url ) ); + } + /** * Checkboxes are absent from the POST when unticked. * diff --git a/tests/cases/test-woocommerce-exclusions.php b/tests/cases/test-woocommerce-exclusions.php index b46e64c..06778a3 100644 --- a/tests/cases/test-woocommerce-exclusions.php +++ b/tests/cases/test-woocommerce-exclusions.php @@ -56,6 +56,46 @@ public function provide_store_pages() { ); } + /** + * The metadata injector must skip the same store pages, and has a sharper + * reason to than the proxy: an order-received or account URL carries order + * ids, `wc_order_*` keys and account tokens, and this path would put the + * URL in a cron argument and then send it to CiteCue. + * + * @dataProvider provide_store_pages + * + * @param string $page Stubbed WooCommerce page. + * @return void + */ + public function test_store_pages_are_never_enriched( $page ) { + $this->requires_stub(); + + $this->fake_visitor_request( '/checkout/order-received/42/?key=wc_order_secret' ); + Citecue_Woocommerce_Stub::pretend( $page ); + + $decision = $this->seo_head()->decide(); + + $this->assertFalse( $decision['inject'] ); + $this->assertSame( 'not-eligible', $decision['reason'] ); + $this->assertSame( 0, $this->http->count() ); + $this->assertSame( array(), _get_cron_array() ? array_filter( _get_cron_array(), static fn( $events ) => isset( $events[ Citecue_Seo_Head::REFRESH_HOOK ] ) ) : array() ); + } + + /** + * Even off a store page, an order key riding in the query string is not + * something to hand a third party. + * + * @return void + */ + public function test_an_order_key_never_reaches_the_lookup_url() { + $this->fake_visitor_request( '/a-product/?key=wc_order_secret&order_id=42' ); + + $url = Citecue_Seo_Head::lookup_url(); + + $this->assertStringNotContainsString( 'wc_order_secret', $url ); + $this->assertStringNotContainsString( 'order_id', $url ); + } + /** * `?add-to-cart=` mutates the cart, so it is not a page view at all. * From 11ec398fc508d20e5215c55141b44530c7cb17f4 Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Fri, 7 Aug 2026 21:48:13 +0200 Subject: [PATCH 3/5] Address the CodeRabbit review: close the query-name bypass, make the budgets atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, one of which reopened the hole the previous commit closed. parse_str() does not report the parameter names that are in the URL. It rewrites `.` and space in a name to `_`, so a parameter literally called `x.1` comes back as `x_1` — and the removal list built from those names asked remove_query_arg() to drop something the URL does not contain, leaving the real parameter untouched. `/?x.1=` therefore walked straight past the strip and went back to minting unlimited cache keys and cron arguments for the homepage. Rewritten to split the query string by hand and, more importantly, to KEEP what is recognized rather than REMOVE what is not: a name this code cannot parse is now simply never copied across, so the failure mode is a dropped parameter rather than a bypass. The per-minute budgets were read-then-write, so concurrent requests all read the same count, all found themselves under the limit, and all proceeded — overshooting the ceiling by roughly the concurrency. Where a persistent object cache exists, wp_cache_incr() makes the count exact. Where one does not there is nothing to be exact with, since the options table has no atomic increment reachable through the transient API, so that path stays best-effort and says so. Shared by both budgets rather than fixing only the new one: the crawler path's lookup budget had the identical weakness, and leaving a weaker sibling beside the fixed one is a trap for whoever reads this next. And two smaller ones: - test_store_pages_are_never_enriched asked about `/checkout/order-received/42/`, which is a 404 on the test site, and the injector declines a 404 several checks before it reaches the store-page rule — so the test would have passed with the WooCommerce exclusion deleted. It now uses a URL that resolves and asserts that it does. - The Performance section still described the lookup budget as covering the crawler and llms.txt paths, which stopped being true when the metadata refresh started sharing it. 317 tests pass on both passes; phpcs and lint clean. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- includes/class-citecue-cache.php | 47 ++++++++++++++++----- includes/class-citecue-seo-head.php | 29 +++++++++---- tests/cases/test-seo-head-delivery.php | 20 +++++++++ tests/cases/test-woocommerce-exclusions.php | 7 ++- 5 files changed, 85 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 938a2b1..2287701 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ A human page view with metadata switched off does no HTTP, no extra database que For a crawler request the cost is one API call, with a 3 s timeout, and only when the local cache cannot answer: optimized bodies are cached for 24 h and revalidated with an ETag, misses are negative-cached for 60 s, and llms.txt is treated as fresh for 5 minutes. A repeat crawl of a cached page is a 304, not a re-download. - **A persistent object cache is recommended.** Cached bodies are transients. With Redis or Memcached they never touch the database. Without one they are rows in `wp_options` — full HTML documents, one per crawled URL, for up to 24 h. They are not autoloaded, so they cost nothing per request, but a heavily crawled site can hold tens of megabytes there until WordPress's twice-daily transient cleanup runs. -- **The outbound-call ceiling is per site, not per path.** The 120/minute budget covers crawler lookups and llms.txt together, so no mix of traffic can exceed it. +- **The outbound-call ceiling is per site, not per path.** The 120/minute budget covers crawler lookups, llms.txt and metadata refreshes together, so no mix of traffic can exceed it. Queueing a metadata refresh has its own, separate per-minute cap (20), because that one is spent on the render path where a visitor chooses how many URLs to ask about. ## What happens if CiteCue is unavailable diff --git a/includes/class-citecue-cache.php b/includes/class-citecue-cache.php index bb48a73..7ead225 100644 --- a/includes/class-citecue-cache.php +++ b/includes/class-citecue-cache.php @@ -190,8 +190,42 @@ public function consume_lookup_budget() { * * @param int $limit Default 120. */ - $limit = max( 1, (int) apply_filters( 'citecue_lookup_budget', 120 ) ); - $key = 'citecue_budget_' . (int) floor( time() / MINUTE_IN_SECONDS ); + return $this->consume_minute_budget( 'citecue_budget_', apply_filters( 'citecue_lookup_budget', 120 ) ); + } + + /** + * Consumes one unit of a per-minute counter, as close to atomically as the + * site's object cache allows. + * + * Read-then-write is a race (PR #10 review): concurrent requests all read + * the same count, all find themselves under the limit, and all proceed, so + * the ceiling can be overshot by roughly the concurrency. `wp_cache_incr()` + * is a single operation on the backing store, so where a persistent object + * cache exists the count is exact. + * + * Without one there is nothing to be exact with — the options table has no + * atomic increment reachable through the transient API — so that path stays + * best-effort by necessity, and both callers are rate limits whose failure + * mode is a bounded overshoot rather than an unbounded one. Shared by both + * budgets so they cannot drift apart on this. + * + * @param string $prefix Transient/cache key prefix, including the trailing separator. + * @param int $limit Units allowed in one minute. + * @return bool Whether a unit was available. + */ + private function consume_minute_budget( $prefix, $limit ) { + $limit = max( 1, (int) $limit ); + $key = $prefix . (int) floor( time() / MINUTE_IN_SECONDS ); + + if ( wp_using_ext_object_cache() ) { + wp_cache_add( $key, 0, 'citecue', 2 * MINUTE_IN_SECONDS ); + $count = wp_cache_incr( $key, 1, 'citecue' ); + // False means the entry was evicted between the add and the + // increment. A fresh bucket is the safe reading: refusing here + // would stop serving over a cache eviction. + return false === $count || $count <= $limit; + } + $count = (int) get_transient( $key ); if ( $count >= $limit ) { return false; @@ -296,14 +330,7 @@ public function consume_seo_head_schedule_budget() { * * @param int $limit Default 20. */ - $limit = max( 1, (int) apply_filters( 'citecue_seo_head_schedule_budget', 20 ) ); - $key = 'citecue_shb_' . (int) floor( time() / MINUTE_IN_SECONDS ); - $count = (int) get_transient( $key ); - if ( $count >= $limit ) { - return false; - } - set_transient( $key, $count + 1, 2 * MINUTE_IN_SECONDS ); - return true; + return $this->consume_minute_budget( 'citecue_shb_', apply_filters( 'citecue_seo_head_schedule_budget', 20 ) ); } /** diff --git a/includes/class-citecue-seo-head.php b/includes/class-citecue-seo-head.php index 89c7774..d635a24 100644 --- a/includes/class-citecue-seo-head.php +++ b/includes/class-citecue-seo-head.php @@ -352,18 +352,31 @@ public static function lookup_url() { return $url; } - $pairs = array(); - parse_str( $query, $pairs ); - + // Split the query string by hand rather than with parse_str(), and keep + // what is allowed rather than removing what is not (PR #10 review). + // parse_str() rewrites `.` and space in a parameter name to `_`, so it + // reports `x_1` for a parameter literally named `x.1` — and a removal + // list built from those names asks remove_query_arg() to drop something + // the URL does not contain, leaving the real parameter in place. That + // is the whole bypass this function exists to prevent, so it is built + // the other way round: an unrecognized name is not removed, it is + // simply never copied across, and a name this code cannot parse can + // therefore never survive. $allowed = self::allowed_query_vars(); - $drop = array(); - foreach ( array_keys( $pairs ) as $key ) { - if ( ! in_array( (string) $key, $allowed, true ) ) { - $drop[] = (string) $key; + $keep = array(); + foreach ( explode( '&', $query ) as $pair ) { + if ( '' === $pair ) { + continue; + } + $name = urldecode( explode( '=', $pair, 2 )[0] ); + if ( in_array( $name, $allowed, true ) ) { + $keep[] = $pair; } } - return $drop ? (string) remove_query_arg( $drop, $url ) : $url; + $base = explode( '?', $url, 2 )[0]; + + return $keep ? $base . '?' . implode( '&', $keep ) : $base; } /** diff --git a/tests/cases/test-seo-head-delivery.php b/tests/cases/test-seo-head-delivery.php index 13d0533..66b4540 100644 --- a/tests/cases/test-seo-head-delivery.php +++ b/tests/cases/test-seo-head-delivery.php @@ -192,6 +192,26 @@ public function test_unknown_query_arguments_never_reach_the_lookup_url() { $this->assertStringNotContainsString( 'utm_source', $url ); } + /** + * A dot or a space in the parameter name is the bypass this had to survive: + * parse_str() reports `x.1` as `x_1`, so a strip built on removing the + * names it reports would ask for a name the URL does not contain and leave + * the real one in place — unbounded cache keys again. + * + * @return void + */ + public function test_query_names_that_parse_str_would_rewrite_are_still_stripped() { + $this->configure_delivery(); + $this->fake_visitor_request( '/?x.1=abcdef&y%20z=1' ); + + $url = Citecue_Seo_Head::lookup_url(); + + $this->assertStringNotContainsString( 'abcdef', $url ); + $this->assertStringNotContainsString( 'x.1', $url ); + $this->assertStringNotContainsString( 'y%20z', $url ); + $this->assertStringNotContainsString( '?', $url ); + } + /** * Stripping must not break a plain-permalink site, where the query string * is how a page is addressed at all. diff --git a/tests/cases/test-woocommerce-exclusions.php b/tests/cases/test-woocommerce-exclusions.php index 06778a3..4e111ca 100644 --- a/tests/cases/test-woocommerce-exclusions.php +++ b/tests/cases/test-woocommerce-exclusions.php @@ -70,8 +70,13 @@ public function provide_store_pages() { public function test_store_pages_are_never_enriched( $page ) { $this->requires_stub(); - $this->fake_visitor_request( '/checkout/order-received/42/?key=wc_order_secret' ); + // A URL that actually resolves. `/checkout/order-received/42/` is a 404 + // on this test site, and the injector declines a 404 several checks + // before it reaches the store-page rule — so the assertion below would + // have held with the WooCommerce exclusion deleted (PR #10 review). + $this->fake_visitor_request( '/a-product/?key=wc_order_secret' ); Citecue_Woocommerce_Stub::pretend( $page ); + $this->assertFalse( is_404(), 'The request must resolve, or this proves nothing about the store-page rule.' ); $decision = $this->seo_head()->decide(); From 9309c07e059ea2a3fe8e1065cb76a5689d6d1640 Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Fri, 7 Aug 2026 21:53:04 +0200 Subject: [PATCH 4/5] Never let a failed increment answer "allowed" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the previous commit's own new code, from a second CodeRabbit pass. `wp_cache_incr()` returning false was read as a fresh bucket and allowed. That is right for the case it was written for — an entry evicted between the add and the increment — and wrong for the one it did not consider: a backend that is not answering fails EVERY increment, so every call returns "allowed" and the ceiling stops existing. Fail-open is the one outcome a rate limit may not have. A failed increment now falls through to the transient counter instead. Atomic where the object cache can be, counted where it cannot, never unlimited. Also pins the counter's edge directly: it grants exactly its limit and then refuses. Both per-minute ceilings share it now, so one off-by-one there is one off-by-one in the crawler path too. 318 tests pass on both passes; phpcs and lint clean. Co-Authored-By: Claude Opus 5 --- includes/class-citecue-cache.php | 19 +++++++++++++------ tests/cases/test-seo-head-delivery.php | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/includes/class-citecue-cache.php b/includes/class-citecue-cache.php index 7ead225..26dc9de 100644 --- a/includes/class-citecue-cache.php +++ b/includes/class-citecue-cache.php @@ -206,8 +206,10 @@ public function consume_lookup_budget() { * Without one there is nothing to be exact with — the options table has no * atomic increment reachable through the transient API — so that path stays * best-effort by necessity, and both callers are rate limits whose failure - * mode is a bounded overshoot rather than an unbounded one. Shared by both - * budgets so they cannot drift apart on this. + * mode is a bounded overshoot rather than an unbounded one. It is also + * where a failed increment lands, so no failure of the fast path can ever + * answer "allowed" without counting something. Shared by both budgets so + * they cannot drift apart on this. * * @param string $prefix Transient/cache key prefix, including the trailing separator. * @param int $limit Units allowed in one minute. @@ -220,10 +222,15 @@ private function consume_minute_budget( $prefix, $limit ) { if ( wp_using_ext_object_cache() ) { wp_cache_add( $key, 0, 'citecue', 2 * MINUTE_IN_SECONDS ); $count = wp_cache_incr( $key, 1, 'citecue' ); - // False means the entry was evicted between the add and the - // increment. A fresh bucket is the safe reading: refusing here - // would stop serving over a cache eviction. - return false === $count || $count <= $limit; + if ( false !== $count ) { + return $count <= $limit; + } + // The increment failed: the entry was evicted between the add and + // the increment, or the backend is not answering. Fall through to + // the counter below rather than reading it as a fresh bucket + // (CodeRabbit review) — a backend failing every increment would + // then return "allowed" every time and remove the ceiling + // altogether, which is the one outcome a rate limit may not have. } $count = (int) get_transient( $key ); diff --git a/tests/cases/test-seo-head-delivery.php b/tests/cases/test-seo-head-delivery.php index 66b4540..4329ca0 100644 --- a/tests/cases/test-seo-head-delivery.php +++ b/tests/cases/test-seo-head-delivery.php @@ -258,6 +258,27 @@ public function test_scheduling_is_capped_per_minute() { $this->assertSame( 3, $jobs ); } + /** + * The budget hands out exactly its limit and then refuses. Worth pinning + * directly: both per-minute ceilings share this counter, and a rate limit + * that answers "allowed" one time too many is a rate limit that can answer + * it every time. + * + * @return void + */ + public function test_the_schedule_budget_stops_at_its_limit() { + add_filter( 'citecue_seo_head_schedule_budget', static fn() => 3 ); + + $granted = 0; + for ( $i = 0; $i < 10; $i++ ) { + if ( $this->plugin->cache->consume_seo_head_schedule_budget() ) { + ++$granted; + } + } + + $this->assertSame( 3, $granted ); + } + /** * The background worker stores what CiteCue returns. * From 7f994ff749cf07c0682a14b9daf43ba3505a5623 Mon Sep 17 00:00:00 2001 From: henry-idingo Date: Fri, 7 Aug 2026 21:56:18 +0200 Subject: [PATCH 5/5] Assert the half of the 404 test its name already claimed test_404_evicts_and_records_a_miss checked the eviction and not the miss. Without the miss every subsequent page view re-queues the fetch for a URL CiteCue has already said it has nothing for, which is the reason the negative cache exists. Co-Authored-By: Claude Opus 5 --- tests/cases/test-seo-head-delivery.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/cases/test-seo-head-delivery.php b/tests/cases/test-seo-head-delivery.php index 4329ca0..99495b1 100644 --- a/tests/cases/test-seo-head-delivery.php +++ b/tests/cases/test-seo-head-delivery.php @@ -349,6 +349,9 @@ public function test_404_evicts_and_records_a_miss() { $this->assertSame( 'not-optimized', $this->seo_head()->refresh( $url ) ); $this->assertNull( $this->plugin->cache->get_seo_head( $url ) ); + // The half this test was named for but did not check: without the miss, + // every subsequent page view would queue the fetch again. + $this->assertTrue( $this->plugin->cache->is_recent_seo_head_miss( $url ) ); } /**