PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.4
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.4
2.0.4 2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
← All changes | src/Admin/RestApi.php +143 -58 2.0.0 → 2.0.4 View file →
@@ -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,13 +373,29 @@
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).
319 - $url = add_query_arg( 'easyfonts_probe', (string) time(), $target );
390 + // The warm key authorises this unauthenticated self-request to download.
391 + $url = add_query_arg(
392 + array(
393 + 'easyfonts_probe' => Settings::warm_key(),
394 + 'efbust' => (string) time(),
395 + ),
396 + $target
397 + );
320 398
321 399 $response = wp_remote_get(
322 400 $url,
323 401 array(
@@ -401,8 +479,9 @@
401 479 ( new UsageTracker() )->truncate();
402 480
403 481 delete_option( 'easyfonts_processed_local' );
404 482 delete_option( 'easyfonts_processed_external' );
483 + delete_option( 'easyfonts_warm_key' ); // Rotate the loopback secret.
405 484
406 485 $settings = Settings::all();
407 486 $settings['detectors'] = array();
408 487 Settings::save( $settings );
@@ -422,8 +501,9 @@
422 501 ( new Storage() )->purge();
423 502
424 503 delete_option( 'easyfonts_processed_local' );
425 504 delete_option( 'easyfonts_processed_external' );
505 + delete_option( 'easyfonts_warm_key' ); // Rotate the loopback secret.
426 506 delete_transient( 'easyfonts_schema_checked' );
427 507
428 508 $settings = Settings::all();
429 509 $settings['detectors'] = array();
@@ -454,15 +534,9 @@
454 534 * @param WP_REST_Request $request Request.
455 535 * @return WP_REST_Response
456 536 */
457 537 public function beacon( WP_REST_Request $request ): WP_REST_Response {
458 - $nonce = $request->get_header( 'X-WP-Nonce' );
459 -
460 - if ( ! $nonce ) {
461 - $nonce = $request->get_param( '_wpnonce' );
462 - }
463 -
464 - if ( ! wp_verify_nonce( (string) $nonce, 'wp_rest' ) ) {
538 + if ( ! $this->public_request_authorized( $request ) ) {
465 539 return new WP_REST_Response( array( 'ok' => false ), 403 );
466 540 }
467 541
468 542 $params = (array) $request->get_json_params();
@@ -468,20 +542,26 @@
468 542 $params = (array) $request->get_json_params();
469 543 $route = sanitize_text_field( $params['route'] ?? '/' );
470 544 $device = in_array( $params['device'] ?? '', array( 'mobile', 'desktop' ), true ) ? $params['device'] : 'any';
471 545
472 - // Cap the route length to keep the indexed column sane.
473 - $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 );
474 551
475 552 $usage = new UsageTracker();
476 553
477 - // Write-once per route + device: once a fresh decision exists for this
478 - // route/device we ignore further public beacons for it until the
479 - // retention window elapses. This bounds DB writes and neutralises
480 - // repeated-request abuse (an anonymous endpoint must not be writable on
481 - // every hit, nor able to keep flipping persistent flags) — without
482 - // losing any functionality, since the first beacon already captured it.
483 - 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 ) ) {
484 564 return new WP_REST_Response( array( 'ok' => true, 'cached' => true ), 200 );
485 565 }
486 566
487 567 // Hard caps on payload size (defence against oversized public payloads).
@@ -512,46 +592,43 @@
512 592 'above_fold' => ! empty( $r['above_fold'] ) ? 1 : 0,
513 593 );
514 594 }
515 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 +
516 616 if ( ! empty( $records ) ) {
517 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();
518 623 }
519 624
520 - // 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.
521 629 $usage->store_decision( $route, (string) $device, $this->clean_variants( $preload ), $this->clean_variants( $unload ) );
522 630
523 - // Auto-preload above-the-fold fonts the beacon just measured — capped,
524 - // and only where the user hasn't already made a preload choice. Preload
525 - // is a scarce resource: preloading every above-fold face hurts more than
526 - // it helps, so we cap to the first few. Newly-detected fonts already
527 - // default to Load=on at insert time, so used fonts light up automatically;
528 - // this adds the Preload half for the genuine hero fonts only.
529 - if ( Settings::get( 'smart_preload', 1 ) && ! empty( $preload ) ) {
530 - $registry = new Registry();
531 -
532 - /**
533 - * Max number of fonts to auto-preload from a single page's
534 - * above-the-fold set. Keep small — preload is for hero fonts only.
535 - *
536 - * @param int $cap
537 - */
538 - $preload_cap = (int) apply_filters( 'easyfonts_auto_preload_cap', 2 );
539 - $done = 0;
540 -
541 - foreach ( $this->clean_variants( $preload ) as $variant ) {
542 - if ( $done >= $preload_cap ) {
543 - break;
544 - }
545 -
546 - // Respect a user's explicit preload choice: only auto-set when
547 - // preload_user_set = 0 for this variant.
548 - if ( $registry->auto_preload( $variant['family'], $variant['weight'], $variant['style'] ) ) {
549 - $done++;
550 - }
551 - }
552 - }
553 -
554 631 return new WP_REST_Response( array( 'ok' => true ), 200 );
555 632 }
556 633
557 634 /**
@@ -588,15 +665,9 @@
588 665 * @param WP_REST_Request $request Request.
589 666 * @return WP_REST_Response
590 667 */
591 668 public function async_fonts( WP_REST_Request $request ): WP_REST_Response {
592 - $nonce = $request->get_header( 'X-WP-Nonce' );
593 -
594 - if ( ! $nonce ) {
595 - $nonce = $request->get_param( '_wpnonce' );
596 - }
597 -
598 - if ( ! wp_verify_nonce( (string) $nonce, 'wp_rest' ) ) {
669 + if ( ! $this->public_request_authorized( $request ) ) {
599 670 return new WP_REST_Response( array( 'ok' => false ), 403 );
600 671 }
601 672
602 673 if ( ! Settings::get( 'enabled', 1 ) || ! Settings::get( 'async_blocker', 0 ) ) {
@@ -630,10 +701,24 @@
630 701 }
631 702
632 703 if ( count( $stored ) !== $before ) {
633 704 update_option( 'easyfonts_async_urls', array_values( $stored ), false );
634 - // New runtime fonts discovered → invalidate so the next render hosts them.
635 - 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 + }
636 721 }
637 722
638 723 return new WP_REST_Response( array( 'ok' => true, 'queued' => count( $stored ) ), 200 );
639 724 }