| @@ -28,11 +28,50 @@ | ||
| 28 | 28 | * Register hooks. |
| 29 | 29 | */ |
| 30 | 30 | public function boot(): void { |
| 31 | 31 | add_action( 'rest_api_init', array( $this, 'register' ) ); |
| 32 | + // After core's cookie check (priority 100). | |
| 33 | + add_filter( 'rest_authentication_errors', array( $this, 'accept_cached_page_token' ), 101 ); | |
| 32 | 34 | } |
| 33 | 35 | |
| 34 | 36 | /** |
| 37 | + * (2.0.4) Let a page-cached beacon through on its cache-safe token. | |
| 38 | + * | |
| 39 | + * A cached page carries the nonce it was rendered with, which expires | |
| 40 | + * within a day. WordPress rejects ANY REST request carrying an invalid | |
| 41 | + * nonce (403 rest_cookie_invalid_nonce) before the route runs — logged in | |
| 42 | + * or not — so the token those pages also send was never checked, and every | |
| 43 | + * beacon from a day-old cached page failed. Since 2.0.4 the scripts send | |
| 44 | + * the token alone; this covers pages cached by older versions. Only for | |
| 45 | + * the two public endpoints, only with a valid token, and the request then | |
| 46 | + * runs as no user — exactly as if no nonce had been sent. | |
| 47 | + * | |
| 48 | + * @param mixed $result Authentication result so far. | |
| 49 | + * @return mixed | |
| 50 | + */ | |
| 51 | + public function accept_cached_page_token( $result ) { | |
| 52 | + if ( ! is_wp_error( $result ) || 'rest_cookie_invalid_nonce' !== $result->get_error_code() ) { | |
| 53 | + return $result; | |
| 54 | + } | |
| 55 | + | |
| 56 | + $route = isset( $GLOBALS['wp']->query_vars['rest_route'] ) ? untrailingslashit( (string) $GLOBALS['wp']->query_vars['rest_route'] ) : ''; | |
| 57 | + | |
| 58 | + if ( ! in_array( $route, array( '/' . self::NS . '/beacon', '/' . self::NS . '/async-fonts' ), true ) ) { | |
| 59 | + return $result; | |
| 60 | + } | |
| 61 | + | |
| 62 | + $token = isset( $_GET['_eftoken'] ) ? (string) wp_unslash( $_GET['_eftoken'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized | |
| 63 | + | |
| 64 | + if ( '' === $token || ! hash_equals( Settings::beacon_token(), $token ) ) { | |
| 65 | + return $result; | |
| 66 | + } | |
| 67 | + | |
| 68 | + wp_set_current_user( 0 ); | |
| 69 | + | |
| 70 | + return true; | |
| 71 | + } | |
| 72 | + | |
| 73 | + /** | |
| 35 | 74 | * Register routes. |
| 36 | 75 | */ |
| 37 | 76 | public function register(): void { |
| 38 | 77 | $admin = array( $this, 'can_manage' ); |
| @@ -137,8 +176,31 @@ | ||
| 137 | 176 | ) ); |
| 138 | 177 | } |
| 139 | 178 | |
| 140 | 179 | /** |
| 180 | + * Authorise a public (beacon / async-fonts) request: accept the cache-safe | |
| 181 | + * site token, or a valid REST nonce (fresh, uncached pages). | |
| 182 | + * | |
| 183 | + * @param WP_REST_Request $request Request. | |
| 184 | + * @return bool | |
| 185 | + */ | |
| 186 | + private function public_request_authorized( WP_REST_Request $request ): bool { | |
| 187 | + $token = (string) $request->get_param( '_eftoken' ); | |
| 188 | + | |
| 189 | + if ( '' !== $token && hash_equals( Settings::beacon_token(), $token ) ) { | |
| 190 | + return true; | |
| 191 | + } | |
| 192 | + | |
| 193 | + $nonce = $request->get_header( 'X-WP-Nonce' ); | |
| 194 | + | |
| 195 | + if ( ! $nonce ) { | |
| 196 | + $nonce = $request->get_param( '_wpnonce' ); | |
| 197 | + } | |
| 198 | + | |
| 199 | + return (bool) wp_verify_nonce( (string) $nonce, 'wp_rest' ); | |
| 200 | + } | |
| 201 | + | |
| 202 | + /** | |
| 141 | 203 | * Capability gate. |
| 142 | 204 | * |
| 143 | 205 | * @return bool |
| 144 | 206 | */ |
| @@ -311,8 +373,17 @@ | ||
| 311 | 373 | 'easyfonts_optimize_urls', |
| 312 | 374 | array( home_url( '/' ) ) |
| 313 | 375 | ); |
| 314 | 376 | |
| 377 | + // Clear any throttled decision for the target routes so the X-ray beacon | |
| 378 | + // that runs right after this is accepted immediately (a fresh, on-demand | |
| 379 | + // measurement rather than waiting out the throttle window). | |
| 380 | + $tracker = new UsageTracker(); | |
| 381 | + | |
| 382 | + foreach ( (array) $targets as $t ) { | |
| 383 | + $tracker->forget_decision( UsageTracker::route_for( (string) $t ) ); | |
| 384 | + } | |
| 385 | + | |
| 315 | 386 | $ok = 0; |
| 316 | 387 | |
| 317 | 388 | foreach ( (array) $targets as $target ) { |
| 318 | 389 | // Cache-busting param forces a fresh PHP render (bypasses page cache). |
| @@ -408,8 +479,9 @@ | ||
| 408 | 479 | ( new UsageTracker() )->truncate(); |
| 409 | 480 | |
| 410 | 481 | delete_option( 'easyfonts_processed_local' ); |
| 411 | 482 | delete_option( 'easyfonts_processed_external' ); |
| 483 | + delete_option( 'easyfonts_warm_key' ); // Rotate the loopback secret. | |
| 412 | 484 | |
| 413 | 485 | $settings = Settings::all(); |
| 414 | 486 | $settings['detectors'] = array(); |
| 415 | 487 | Settings::save( $settings ); |
| @@ -429,8 +501,9 @@ | ||
| 429 | 501 | ( new Storage() )->purge(); |
| 430 | 502 | |
| 431 | 503 | delete_option( 'easyfonts_processed_local' ); |
| 432 | 504 | delete_option( 'easyfonts_processed_external' ); |
| 505 | + delete_option( 'easyfonts_warm_key' ); // Rotate the loopback secret. | |
| 433 | 506 | delete_transient( 'easyfonts_schema_checked' ); |
| 434 | 507 | |
| 435 | 508 | $settings = Settings::all(); |
| 436 | 509 | $settings['detectors'] = array(); |
| @@ -461,15 +534,9 @@ | ||
| 461 | 534 | * @param WP_REST_Request $request Request. |
| 462 | 535 | * @return WP_REST_Response |
| 463 | 536 | */ |
| 464 | 537 | public function beacon( WP_REST_Request $request ): WP_REST_Response { |
| 465 | - $nonce = $request->get_header( 'X-WP-Nonce' ); | |
| 466 | - | |
| 467 | - if ( ! $nonce ) { | |
| 468 | - $nonce = $request->get_param( '_wpnonce' ); | |
| 469 | - } | |
| 470 | - | |
| 471 | - if ( ! wp_verify_nonce( (string) $nonce, 'wp_rest' ) ) { | |
| 538 | + if ( ! $this->public_request_authorized( $request ) ) { | |
| 472 | 539 | return new WP_REST_Response( array( 'ok' => false ), 403 ); |
| 473 | 540 | } |
| 474 | 541 | |
| 475 | 542 | $params = (array) $request->get_json_params(); |
| @@ -475,20 +542,26 @@ | ||
| 475 | 542 | $params = (array) $request->get_json_params(); |
| 476 | 543 | $route = sanitize_text_field( $params['route'] ?? '/' ); |
| 477 | 544 | $device = in_array( $params['device'] ?? '', array( 'mobile', 'desktop' ), true ) ? $params['device'] : 'any'; |
| 478 | 545 | |
| 479 | - // Cap the route length to keep the indexed column sane. | |
| 480 | - $route = '' === $route ? '/' : substr( $route, 0, 480 ); | |
| 546 | + // Normalise to a canonical path (leading '/', no query/scheme) so | |
| 547 | + // crafted route strings can't mint arbitrary rows, then cap the length | |
| 548 | + // to keep the indexed column sane. | |
| 549 | + $route = UsageTracker::route_for( $route ); | |
| 550 | + $route = substr( $route, 0, 480 ); | |
| 481 | 551 | |
| 482 | 552 | $usage = new UsageTracker(); |
| 483 | 553 | |
| 484 | - // Write-once per route + device: once a fresh decision exists for this | |
| 485 | - // route/device we ignore further public beacons for it until the | |
| 486 | - // retention window elapses. This bounds DB writes and neutralises | |
| 487 | - // repeated-request abuse (an anonymous endpoint must not be writable on | |
| 488 | - // every hit, nor able to keep flipping persistent flags) — without | |
| 489 | - // losing any functionality, since the first beacon already captured it. | |
| 490 | - if ( $usage->has_fresh_decision( $route, (string) $device ) ) { | |
| 554 | + // Per-route/device throttle: once we record a beacon for a route we | |
| 555 | + // ignore further beacons for it for a short window. This bounds writes | |
| 556 | + // on an anonymous endpoint and neutralises repeated-request abuse, while | |
| 557 | + // still letting used/unused classification and page scoping self-correct | |
| 558 | + // quickly (hourly by default) as pages change — the old week-long | |
| 559 | + // write-once meant a font that started or stopped being used wasn't | |
| 560 | + // reflected for days. | |
| 561 | + $throttle = (int) apply_filters( 'easyfonts_beacon_throttle_hours', 1 ); | |
| 562 | + | |
| 563 | + if ( $throttle > 0 && $usage->has_fresh_decision( $route, (string) $device, $throttle ) ) { | |
| 491 | 564 | return new WP_REST_Response( array( 'ok' => true, 'cached' => true ), 200 ); |
| 492 | 565 | } |
| 493 | 566 | |
| 494 | 567 | // Hard caps on payload size (defence against oversized public payloads). |
| @@ -519,46 +592,43 @@ | ||
| 519 | 592 | 'above_fold' => ! empty( $r['above_fold'] ) ? 1 : 0, |
| 520 | 593 | ); |
| 521 | 594 | } |
| 522 | 595 | |
| 596 | + // Also record what loaded but never rendered, as beacon-origin rows with | |
| 597 | + // rendered = 0. This is what lets page scoping tell a CONFIRMED-unused | |
| 598 | + // font (drop it from this page) apart from a font that simply hasn't been | |
| 599 | + // measured yet (keep it) — so a newly added/replaced font is never | |
| 600 | + // wrongly scoped out before the beacon has judged it. | |
| 601 | + foreach ( $unload as $u ) { | |
| 602 | + if ( empty( $u['family'] ) ) { | |
| 603 | + continue; | |
| 604 | + } | |
| 605 | + | |
| 606 | + $records[] = array( | |
| 607 | + 'family' => $u['family'], | |
| 608 | + 'weight' => $u['weight'] ?? '400', | |
| 609 | + 'style' => $u['style'] ?? 'normal', | |
| 610 | + 'origin' => 'beacon', | |
| 611 | + 'rendered' => 0, | |
| 612 | + 'above_fold' => 0, | |
| 613 | + ); | |
| 614 | + } | |
| 615 | + | |
| 523 | 616 | if ( ! empty( $records ) ) { |
| 524 | 617 | $usage->record( $route, url_to_postid( home_url( $route ) ), $records ); |
| 618 | + | |
| 619 | + // Refresh the uniformly-above-the-fold family cache from the updated | |
| 620 | + // usage data. Runs only when a beacon is accepted (throttled), so the | |
| 621 | + // cost is a single grouped scan of the capped usage table. | |
| 622 | + $usage->recompute_global_preload(); | |
| 525 | 623 | } |
| 526 | 624 | |
| 527 | - // Store the decision for future preloads/unloads. | |
| 625 | + // Persist the raw beacon measurement for this route/device. Page scoping | |
| 626 | + // and preload are driven from the recorded usage above (family-level, | |
| 627 | + // self-correcting); this row anchors the per-route throttle (updated_at) | |
| 628 | + // and keeps the raw above-the-fold/unrendered snapshot for diagnostics. | |
| 528 | 629 | $usage->store_decision( $route, (string) $device, $this->clean_variants( $preload ), $this->clean_variants( $unload ) ); |
| 529 | 630 | |
| 530 | - // Auto-preload above-the-fold fonts the beacon just measured — capped, | |
| 531 | - // and only where the user hasn't already made a preload choice. Preload | |
| 532 | - // is a scarce resource: preloading every above-fold face hurts more than | |
| 533 | - // it helps, so we cap to the first few. Newly-detected fonts already | |
| 534 | - // default to Load=on at insert time, so used fonts light up automatically; | |
| 535 | - // this adds the Preload half for the genuine hero fonts only. | |
| 536 | - if ( Settings::get( 'smart_preload', 1 ) && ! empty( $preload ) ) { | |
| 537 | - $registry = new Registry(); | |
| 538 | - | |
| 539 | - /** | |
| 540 | - * Max number of fonts to auto-preload from a single page's | |
| 541 | - * above-the-fold set. Keep small — preload is for hero fonts only. | |
| 542 | - * | |
| 543 | - * @param int $cap | |
| 544 | - */ | |
| 545 | - $preload_cap = (int) apply_filters( 'easyfonts_auto_preload_cap', 2 ); | |
| 546 | - $done = 0; | |
| 547 | - | |
| 548 | - foreach ( $this->clean_variants( $preload ) as $variant ) { | |
| 549 | - if ( $done >= $preload_cap ) { | |
| 550 | - break; | |
| 551 | - } | |
| 552 | - | |
| 553 | - // Respect a user's explicit preload choice: only auto-set when | |
| 554 | - // preload_user_set = 0 for this variant. | |
| 555 | - if ( $registry->auto_preload( $variant['family'], $variant['weight'], $variant['style'] ) ) { | |
| 556 | - $done++; | |
| 557 | - } | |
| 558 | - } | |
| 559 | - } | |
| 560 | - | |
| 561 | 631 | return new WP_REST_Response( array( 'ok' => true ), 200 ); |
| 562 | 632 | } |
| 563 | 633 | |
| 564 | 634 | /** |
| @@ -595,15 +665,9 @@ | ||
| 595 | 665 | * @param WP_REST_Request $request Request. |
| 596 | 666 | * @return WP_REST_Response |
| 597 | 667 | */ |
| 598 | 668 | public function async_fonts( WP_REST_Request $request ): WP_REST_Response { |
| 599 | - $nonce = $request->get_header( 'X-WP-Nonce' ); | |
| 600 | - | |
| 601 | - if ( ! $nonce ) { | |
| 602 | - $nonce = $request->get_param( '_wpnonce' ); | |
| 603 | - } | |
| 604 | - | |
| 605 | - if ( ! wp_verify_nonce( (string) $nonce, 'wp_rest' ) ) { | |
| 669 | + if ( ! $this->public_request_authorized( $request ) ) { | |
| 606 | 670 | return new WP_REST_Response( array( 'ok' => false ), 403 ); |
| 607 | 671 | } |
| 608 | 672 | |
| 609 | 673 | if ( ! Settings::get( 'enabled', 1 ) || ! Settings::get( 'async_blocker', 0 ) ) { |
| @@ -637,10 +701,24 @@ | ||
| 637 | 701 | } |
| 638 | 702 | |
| 639 | 703 | if ( count( $stored ) !== $before ) { |
| 640 | 704 | update_option( 'easyfonts_async_urls', array_values( $stored ), false ); |
| 641 | - // New runtime fonts discovered → invalidate so the next render hosts them. | |
| 642 | - Settings::bump_buster(); | |
| 705 | + | |
| 706 | + // New runtime fonts discovered → invalidate so the next render hosts | |
| 707 | + // them. Rate-limited: this is public input, and every bump drops the | |
| 708 | + // CSS fetch cache site-wide, so crafted unique URLs must not be able | |
| 709 | + // to thrash it. | |
| 710 | + if ( ! get_transient( 'easyfonts_async_bump_lock' ) ) { | |
| 711 | + /** | |
| 712 | + * Minimum seconds between async-endpoint cache-buster bumps. | |
| 713 | + * | |
| 714 | + * @param int $interval | |
| 715 | + */ | |
| 716 | + $interval = max( 60, (int) apply_filters( 'easyfonts_async_bump_interval', 5 * MINUTE_IN_SECONDS ) ); | |
| 717 | + | |
| 718 | + set_transient( 'easyfonts_async_bump_lock', 1, $interval ); | |
| 719 | + Settings::bump_buster(); | |
| 720 | + } | |
| 643 | 721 | } |
| 644 | 722 | |
| 645 | 723 | return new WP_REST_Response( array( 'ok' => true, 'queued' => count( $stored ) ), 200 ); |
| 646 | 724 | } |