| 1 |
<?php |
| 2 |
/** |
| 3 |
* REST API. |
| 4 |
* |
| 5 |
* @package EasyFonts |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace EasyFonts\Admin; |
| 9 |
|
| 10 |
use EasyFonts\Database\Migrator; |
| 11 |
use EasyFonts\Fonts\Registry; |
| 12 |
use EasyFonts\Fonts\Storage; |
| 13 |
use EasyFonts\Fonts\UsageTracker; |
| 14 |
use EasyFonts\Settings; |
| 15 |
use WP_REST_Request; |
| 16 |
use WP_REST_Response; |
| 17 |
|
| 18 |
defined( 'ABSPATH' ) || exit; |
| 19 |
|
| 20 |
/** |
| 21 |
* Registers and handles all REST routes. |
| 22 |
*/ |
| 23 |
class RestApi { |
| 24 |
|
| 25 |
const NS = 'easyfonts/v1'; |
| 26 |
|
| 27 |
/** |
| 28 |
* Register hooks. |
| 29 |
*/ |
| 30 |
public function boot(): void { |
| 31 |
add_action( 'rest_api_init', array( $this, 'register' ) ); |
| 32 |
} |
| 33 |
|
| 34 |
/** |
| 35 |
* Register routes. |
| 36 |
*/ |
| 37 |
public function register(): void { |
| 38 |
$admin = array( $this, 'can_manage' ); |
| 39 |
|
| 40 |
register_rest_route( |
| 41 |
self::NS, |
| 42 |
'/settings', |
| 43 |
array( |
| 44 |
array( |
| 45 |
'methods' => 'GET', |
| 46 |
'callback' => array( $this, 'get_settings' ), |
| 47 |
'permission_callback' => $admin, |
| 48 |
), |
| 49 |
array( |
| 50 |
'methods' => 'POST', |
| 51 |
'callback' => array( $this, 'save_settings' ), |
| 52 |
'permission_callback' => $admin, |
| 53 |
), |
| 54 |
) |
| 55 |
); |
| 56 |
|
| 57 |
register_rest_route( self::NS, '/fonts', array( |
| 58 |
'methods' => 'GET', |
| 59 |
'callback' => array( $this, 'get_fonts' ), |
| 60 |
'permission_callback' => $admin, |
| 61 |
) ); |
| 62 |
|
| 63 |
register_rest_route( self::NS, '/font/toggle', array( |
| 64 |
'methods' => 'POST', |
| 65 |
'callback' => array( $this, 'toggle_font' ), |
| 66 |
'permission_callback' => $admin, |
| 67 |
) ); |
| 68 |
|
| 69 |
register_rest_route( self::NS, '/fonts/bulk', array( |
| 70 |
'methods' => 'POST', |
| 71 |
'callback' => array( $this, 'bulk_toggle' ), |
| 72 |
'permission_callback' => $admin, |
| 73 |
) ); |
| 74 |
|
| 75 |
register_rest_route( self::NS, '/optimize', array( |
| 76 |
'methods' => 'POST', |
| 77 |
'callback' => array( $this, 'optimize' ), |
| 78 |
'permission_callback' => $admin, |
| 79 |
) ); |
| 80 |
|
| 81 |
register_rest_route( self::NS, '/usage', array( |
| 82 |
'methods' => 'GET', |
| 83 |
'callback' => array( $this, 'get_usage' ), |
| 84 |
'permission_callback' => $admin, |
| 85 |
) ); |
| 86 |
|
| 87 |
register_rest_route( self::NS, '/stats', array( |
| 88 |
'methods' => 'GET', |
| 89 |
'callback' => array( $this, 'get_stats' ), |
| 90 |
'permission_callback' => $admin, |
| 91 |
) ); |
| 92 |
|
| 93 |
register_rest_route( self::NS, '/purge', array( |
| 94 |
'methods' => 'POST', |
| 95 |
'callback' => array( $this, 'purge' ), |
| 96 |
'permission_callback' => $admin, |
| 97 |
) ); |
| 98 |
|
| 99 |
register_rest_route( self::NS, '/repair', array( |
| 100 |
'methods' => 'POST', |
| 101 |
'callback' => array( $this, 'repair' ), |
| 102 |
'permission_callback' => $admin, |
| 103 |
) ); |
| 104 |
|
| 105 |
register_rest_route( self::NS, '/diagnostics', array( |
| 106 |
'methods' => 'GET', |
| 107 |
'callback' => array( $this, 'diagnostics' ), |
| 108 |
'permission_callback' => $admin, |
| 109 |
) ); |
| 110 |
|
| 111 |
// Public beacon ingest (nonce-checked in handler). |
| 112 |
register_rest_route( self::NS, '/beacon', array( |
| 113 |
'methods' => 'POST', |
| 114 |
'callback' => array( $this, 'beacon' ), |
| 115 |
'permission_callback' => '__return_true', |
| 116 |
) ); |
| 117 |
|
| 118 |
// Public async-font ingest: records Google/Bunny CSS URLs injected by JS |
| 119 |
// at runtime so the next render can self-host them (nonce-checked). |
| 120 |
register_rest_route( self::NS, '/async-fonts', array( |
| 121 |
'methods' => 'POST', |
| 122 |
'callback' => array( $this, 'async_fonts' ), |
| 123 |
'permission_callback' => '__return_true', |
| 124 |
) ); |
| 125 |
|
| 126 |
// Settings export / import (admin only). |
| 127 |
register_rest_route( self::NS, '/export', array( |
| 128 |
'methods' => 'GET', |
| 129 |
'callback' => array( $this, 'export_settings' ), |
| 130 |
'permission_callback' => $admin, |
| 131 |
) ); |
| 132 |
|
| 133 |
register_rest_route( self::NS, '/import', array( |
| 134 |
'methods' => 'POST', |
| 135 |
'callback' => array( $this, 'import_settings' ), |
| 136 |
'permission_callback' => $admin, |
| 137 |
) ); |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Authorise a public (beacon / async-fonts) request: accept the cache-safe |
| 142 |
* site token, or a valid REST nonce (fresh, uncached pages). |
| 143 |
* |
| 144 |
* @param WP_REST_Request $request Request. |
| 145 |
* @return bool |
| 146 |
*/ |
| 147 |
private function public_request_authorized( WP_REST_Request $request ): bool { |
| 148 |
$token = (string) $request->get_param( '_eftoken' ); |
| 149 |
|
| 150 |
if ( '' !== $token && hash_equals( Settings::beacon_token(), $token ) ) { |
| 151 |
return true; |
| 152 |
} |
| 153 |
|
| 154 |
$nonce = $request->get_header( 'X-WP-Nonce' ); |
| 155 |
|
| 156 |
if ( ! $nonce ) { |
| 157 |
$nonce = $request->get_param( '_wpnonce' ); |
| 158 |
} |
| 159 |
|
| 160 |
return (bool) wp_verify_nonce( (string) $nonce, 'wp_rest' ); |
| 161 |
} |
| 162 |
|
| 163 |
/** |
| 164 |
* Capability gate. |
| 165 |
* |
| 166 |
* @return bool |
| 167 |
*/ |
| 168 |
public function can_manage(): bool { |
| 169 |
return current_user_can( 'manage_options' ); |
| 170 |
} |
| 171 |
|
| 172 |
/** |
| 173 |
* GET /settings |
| 174 |
* |
| 175 |
* @return WP_REST_Response |
| 176 |
*/ |
| 177 |
public function get_settings(): WP_REST_Response { |
| 178 |
return new WP_REST_Response( Settings::all(), 200 ); |
| 179 |
} |
| 180 |
|
| 181 |
/** |
| 182 |
* POST /settings |
| 183 |
* |
| 184 |
* @param WP_REST_Request $request Request. |
| 185 |
* @return WP_REST_Response |
| 186 |
*/ |
| 187 |
public function save_settings( WP_REST_Request $request ): WP_REST_Response { |
| 188 |
$input = (array) $request->get_json_params(); |
| 189 |
$current = Settings::all(); |
| 190 |
|
| 191 |
$bools = array( 'enabled', 'auto_config', 'strip_hints', 'smart_preload', 'metric_fallbacks', 'beacon', 'inline_css', 'async_blocker', 'remove_data_on_uninstall' ); |
| 192 |
|
| 193 |
foreach ( $bools as $key ) { |
| 194 |
if ( array_key_exists( $key, $input ) ) { |
| 195 |
$current[ $key ] = $input[ $key ] ? 1 : 0; |
| 196 |
} |
| 197 |
} |
| 198 |
|
| 199 |
if ( isset( $input['font_display'] ) ) { |
| 200 |
$allowed = array( 'swap', 'fallback', 'optional', 'block', 'auto', 'none' ); |
| 201 |
$val = sanitize_text_field( $input['font_display'] ); |
| 202 |
$current['font_display'] = in_array( $val, $allowed, true ) ? $val : 'swap'; |
| 203 |
} |
| 204 |
|
| 205 |
if ( array_key_exists( 'cdn_url', $input ) ) { |
| 206 |
$cdn = trim( (string) $input['cdn_url'] ); |
| 207 |
|
| 208 |
// Accept protocol-relative; require http(s); store without trailing slash. |
| 209 |
if ( '' === $cdn ) { |
| 210 |
$current['cdn_url'] = ''; |
| 211 |
} else { |
| 212 |
if ( 0 === strpos( $cdn, '//' ) ) { |
| 213 |
$cdn = ( is_ssl() ? 'https:' : 'http:' ) . $cdn; |
| 214 |
} |
| 215 |
|
| 216 |
$current['cdn_url'] = preg_match( '#^https?://#i', $cdn ) |
| 217 |
? untrailingslashit( esc_url_raw( $cdn ) ) |
| 218 |
: ''; |
| 219 |
} |
| 220 |
} |
| 221 |
|
| 222 |
if ( isset( $input['subsets'] ) && is_array( $input['subsets'] ) ) { |
| 223 |
$current['subsets'] = array_values( array_map( 'sanitize_text_field', $input['subsets'] ) ); |
| 224 |
} |
| 225 |
|
| 226 |
if ( isset( $input['excluded_urls'] ) && is_array( $input['excluded_urls'] ) ) { |
| 227 |
$current['excluded_urls'] = array_values( array_filter( array_map( 'sanitize_text_field', $input['excluded_urls'] ) ) ); |
| 228 |
} |
| 229 |
|
| 230 |
Settings::save( $current ); |
| 231 |
Settings::bump_buster(); |
| 232 |
|
| 233 |
return new WP_REST_Response( Settings::all(), 200 ); |
| 234 |
} |
| 235 |
|
| 236 |
/** |
| 237 |
* GET /fonts |
| 238 |
* |
| 239 |
* @return WP_REST_Response |
| 240 |
*/ |
| 241 |
public function get_fonts(): WP_REST_Response { |
| 242 |
$rendered = ( new UsageTracker() )->rendered_set(); |
| 243 |
|
| 244 |
$grouped = ( new Registry() )->grouped_with_usage( |
| 245 |
$rendered['variants'], |
| 246 |
$rendered['families'] |
| 247 |
); |
| 248 |
|
| 249 |
return new WP_REST_Response( $grouped, 200 ); |
| 250 |
} |
| 251 |
|
| 252 |
/** |
| 253 |
* POST /font/toggle — enable/disable or set preload on a variant or family. |
| 254 |
* |
| 255 |
* Body: { field: 'enabled'|'preloaded', value: bool, family: string, |
| 256 |
* weight?: string, style?: string } |
| 257 |
* Omitting weight/style targets the whole family. |
| 258 |
* |
| 259 |
* @param WP_REST_Request $request Request. |
| 260 |
* @return WP_REST_Response |
| 261 |
*/ |
| 262 |
public function toggle_font( WP_REST_Request $request ): WP_REST_Response { |
| 263 |
$params = (array) $request->get_json_params(); |
| 264 |
|
| 265 |
$field = sanitize_text_field( $params['field'] ?? '' ); |
| 266 |
$family = sanitize_text_field( $params['family'] ?? '' ); |
| 267 |
$weight = isset( $params['weight'] ) ? sanitize_text_field( (string) $params['weight'] ) : ''; |
| 268 |
$style = isset( $params['style'] ) ? sanitize_text_field( (string) $params['style'] ) : ''; |
| 269 |
$value = ! empty( $params['value'] ); |
| 270 |
|
| 271 |
$column = 'enabled' === $field ? 'is_enabled' : ( 'preloaded' === $field ? 'is_preloaded' : '' ); |
| 272 |
|
| 273 |
if ( '' === $column || '' === $family ) { |
| 274 |
return new WP_REST_Response( array( 'ok' => false, 'error' => 'bad_request' ), 400 ); |
| 275 |
} |
| 276 |
|
| 277 |
( new Registry() )->set_flag( $column, $value, $family, $weight, $style ); |
| 278 |
|
| 279 |
// Enabling/disabling changes which fonts the stylesheet contains, so the |
| 280 |
// next render must rebuild it; bump the buster to invalidate caches. |
| 281 |
if ( 'is_enabled' === $column ) { |
| 282 |
Settings::bump_buster(); |
| 283 |
} |
| 284 |
|
| 285 |
return new WP_REST_Response( array( 'ok' => true ), 200 ); |
| 286 |
} |
| 287 |
|
| 288 |
/** |
| 289 |
* POST /fonts/bulk — enable/disable (or preload) every font in a scope at |
| 290 |
* once. Body: { field:'enabled'|'preloaded', value:bool, scope:'used'|'unused'|'all' } |
| 291 |
* |
| 292 |
* @param WP_REST_Request $request Request. |
| 293 |
* @return WP_REST_Response |
| 294 |
*/ |
| 295 |
public function bulk_toggle( WP_REST_Request $request ): WP_REST_Response { |
| 296 |
$params = (array) $request->get_json_params(); |
| 297 |
|
| 298 |
$field = 'preloaded' === ( $params['field'] ?? '' ) ? 'is_preloaded' : 'is_enabled'; |
| 299 |
$value = ! empty( $params['value'] ); |
| 300 |
$scope = in_array( $params['scope'] ?? 'all', array( 'used', 'unused', 'all' ), true ) ? $params['scope'] : 'all'; |
| 301 |
|
| 302 |
$registry = new Registry(); |
| 303 |
|
| 304 |
if ( 'all' === $scope ) { |
| 305 |
$registry->set_flag_all( $field, $value ); |
| 306 |
} else { |
| 307 |
$rendered = ( new UsageTracker() )->rendered_set(); |
| 308 |
$grouped = $registry->grouped_with_usage( $rendered['variants'], $rendered['families'] ); |
| 309 |
|
| 310 |
foreach ( $grouped as $family ) { |
| 311 |
$is_used = ! empty( $family['used'] ); |
| 312 |
|
| 313 |
if ( ( 'used' === $scope && $is_used ) || ( 'unused' === $scope && ! $is_used ) ) { |
| 314 |
$registry->set_flag( $field, $value, $family['family'] ); |
| 315 |
} |
| 316 |
} |
| 317 |
} |
| 318 |
|
| 319 |
if ( 'is_enabled' === $field ) { |
| 320 |
Settings::bump_buster(); |
| 321 |
} |
| 322 |
|
| 323 |
return new WP_REST_Response( array( 'ok' => true ), 200 ); |
| 324 |
} |
| 325 |
|
| 326 |
/** |
| 327 |
* POST /optimize — prime optimisation now via a cache-busting loopback to |
| 328 |
* the front page, so the user doesn't have to visit it manually. |
| 329 |
* |
| 330 |
* @return WP_REST_Response |
| 331 |
*/ |
| 332 |
public function optimize(): WP_REST_Response { |
| 333 |
$targets = apply_filters( |
| 334 |
'easyfonts_optimize_urls', |
| 335 |
array( home_url( '/' ) ) |
| 336 |
); |
| 337 |
|
| 338 |
// Clear any throttled decision for the target routes so the X-ray beacon |
| 339 |
// that runs right after this is accepted immediately (a fresh, on-demand |
| 340 |
// measurement rather than waiting out the throttle window). |
| 341 |
$tracker = new UsageTracker(); |
| 342 |
|
| 343 |
foreach ( (array) $targets as $t ) { |
| 344 |
$tracker->forget_decision( UsageTracker::route_for( (string) $t ) ); |
| 345 |
} |
| 346 |
|
| 347 |
$ok = 0; |
| 348 |
|
| 349 |
foreach ( (array) $targets as $target ) { |
| 350 |
// Cache-busting param forces a fresh PHP render (bypasses page cache). |
| 351 |
// The warm key authorises this unauthenticated self-request to download. |
| 352 |
$url = add_query_arg( |
| 353 |
array( |
| 354 |
'easyfonts_probe' => Settings::warm_key(), |
| 355 |
'efbust' => (string) time(), |
| 356 |
), |
| 357 |
$target |
| 358 |
); |
| 359 |
|
| 360 |
$response = wp_remote_get( |
| 361 |
$url, |
| 362 |
array( |
| 363 |
'timeout' => 30, |
| 364 |
/** |
| 365 |
* Verify SSL on the loopback self-request. True by default; |
| 366 |
* filter to false only for self-signed/staging certs. |
| 367 |
* |
| 368 |
* @param bool $verify |
| 369 |
*/ |
| 370 |
'sslverify' => (bool) apply_filters( 'easyfonts_loopback_sslverify', true ), |
| 371 |
) |
| 372 |
); |
| 373 |
|
| 374 |
if ( ! is_wp_error( $response ) && (int) wp_remote_retrieve_response_code( $response ) < 400 ) { |
| 375 |
$ok++; |
| 376 |
} |
| 377 |
} |
| 378 |
|
| 379 |
$stats = ( new Registry() )->stats(); |
| 380 |
|
| 381 |
return new WP_REST_Response( |
| 382 |
array( |
| 383 |
'ok' => $ok > 0, |
| 384 |
'probed' => $ok, |
| 385 |
'families' => $stats['families'], |
| 386 |
'variants' => $stats['variants'], |
| 387 |
// The admin loads this in a hidden iframe to run the real-browser |
| 388 |
// X-ray (beacon), which measures rendered fonts + subsets. |
| 389 |
'probe_url' => add_query_arg( 'easyfonts_probe', (string) time(), home_url( '/' ) ), |
| 390 |
// Snapshot of the last-beacon time BEFORE the X-ray; the client |
| 391 |
// polls /stats and watches for this to advance, which confirms the |
| 392 |
// iframe actually rendered (and wasn't blocked by X-Frame-Options). |
| 393 |
'beacon_before' => (int) get_option( 'easyfonts_last_beacon', 0 ), |
| 394 |
), |
| 395 |
200 |
| 396 |
); |
| 397 |
} |
| 398 |
|
| 399 |
/** |
| 400 |
* GET /usage |
| 401 |
* |
| 402 |
* @return WP_REST_Response |
| 403 |
*/ |
| 404 |
public function get_usage(): WP_REST_Response { |
| 405 |
return new WP_REST_Response( ( new UsageTracker() )->summary(), 200 ); |
| 406 |
} |
| 407 |
|
| 408 |
/** |
| 409 |
* GET /stats |
| 410 |
* |
| 411 |
* @return WP_REST_Response |
| 412 |
*/ |
| 413 |
public function get_stats(): WP_REST_Response { |
| 414 |
$registry = new Registry(); |
| 415 |
$storage = new Storage(); |
| 416 |
$stats = $registry->stats(); |
| 417 |
|
| 418 |
return new WP_REST_Response( |
| 419 |
array( |
| 420 |
'families' => $stats['families'], |
| 421 |
'variants' => $stats['variants'], |
| 422 |
'hosted_kb' => (int) round( $stats['bytes'] / 1024 ), |
| 423 |
'cache_kb' => (int) round( $storage->total_size() / 1024 ), |
| 424 |
'detectors' => (array) Settings::get( 'detectors', array() ), |
| 425 |
'last_buster' => Settings::buster(), |
| 426 |
'last_beacon' => (int) get_option( 'easyfonts_last_beacon', 0 ), |
| 427 |
), |
| 428 |
200 |
| 429 |
); |
| 430 |
} |
| 431 |
|
| 432 |
/** |
| 433 |
* POST /purge — clear cache + tables, keep settings. |
| 434 |
* |
| 435 |
* @return WP_REST_Response |
| 436 |
*/ |
| 437 |
public function purge(): WP_REST_Response { |
| 438 |
( new Storage() )->purge(); |
| 439 |
( new Registry() )->truncate(); |
| 440 |
( new UsageTracker() )->truncate(); |
| 441 |
|
| 442 |
delete_option( 'easyfonts_processed_local' ); |
| 443 |
delete_option( 'easyfonts_processed_external' ); |
| 444 |
delete_option( 'easyfonts_warm_key' ); // Rotate the loopback secret. |
| 445 |
|
| 446 |
$settings = Settings::all(); |
| 447 |
$settings['detectors'] = array(); |
| 448 |
Settings::save( $settings ); |
| 449 |
Settings::bump_buster(); |
| 450 |
|
| 451 |
return new WP_REST_Response( array( 'ok' => true ), 200 ); |
| 452 |
} |
| 453 |
|
| 454 |
/** |
| 455 |
* POST /repair — drop + recreate the tables (fixes stale/legacy schema), |
| 456 |
* then clear cache + learned state so the next page view rebuilds cleanly. |
| 457 |
* |
| 458 |
* @return WP_REST_Response |
| 459 |
*/ |
| 460 |
public function repair(): WP_REST_Response { |
| 461 |
( new Migrator() )->repair(); |
| 462 |
( new Storage() )->purge(); |
| 463 |
|
| 464 |
delete_option( 'easyfonts_processed_local' ); |
| 465 |
delete_option( 'easyfonts_processed_external' ); |
| 466 |
delete_option( 'easyfonts_warm_key' ); // Rotate the loopback secret. |
| 467 |
delete_transient( 'easyfonts_schema_checked' ); |
| 468 |
|
| 469 |
$settings = Settings::all(); |
| 470 |
$settings['detectors'] = array(); |
| 471 |
Settings::save( $settings ); |
| 472 |
Settings::bump_buster(); |
| 473 |
|
| 474 |
return new WP_REST_Response( |
| 475 |
array( |
| 476 |
'ok' => true, |
| 477 |
'tables' => ( new Migrator() )->status(), |
| 478 |
), |
| 479 |
200 |
| 480 |
); |
| 481 |
} |
| 482 |
|
| 483 |
/** |
| 484 |
* GET /diagnostics |
| 485 |
* |
| 486 |
* @return WP_REST_Response |
| 487 |
*/ |
| 488 |
public function diagnostics(): WP_REST_Response { |
| 489 |
return new WP_REST_Response( Diagnostics::collect(), 200 ); |
| 490 |
} |
| 491 |
|
| 492 |
/** |
| 493 |
* POST /beacon — ingest real-render decisions. |
| 494 |
* |
| 495 |
* @param WP_REST_Request $request Request. |
| 496 |
* @return WP_REST_Response |
| 497 |
*/ |
| 498 |
public function beacon( WP_REST_Request $request ): WP_REST_Response { |
| 499 |
if ( ! $this->public_request_authorized( $request ) ) { |
| 500 |
return new WP_REST_Response( array( 'ok' => false ), 403 ); |
| 501 |
} |
| 502 |
|
| 503 |
$params = (array) $request->get_json_params(); |
| 504 |
$route = sanitize_text_field( $params['route'] ?? '/' ); |
| 505 |
$device = in_array( $params['device'] ?? '', array( 'mobile', 'desktop' ), true ) ? $params['device'] : 'any'; |
| 506 |
|
| 507 |
// Normalise to a canonical path (leading '/', no query/scheme) so |
| 508 |
// crafted route strings can't mint arbitrary rows, then cap the length |
| 509 |
// to keep the indexed column sane. |
| 510 |
$route = UsageTracker::route_for( $route ); |
| 511 |
$route = substr( $route, 0, 480 ); |
| 512 |
|
| 513 |
$usage = new UsageTracker(); |
| 514 |
|
| 515 |
// Per-route/device throttle: once we record a beacon for a route we |
| 516 |
// ignore further beacons for it for a short window. This bounds writes |
| 517 |
// on an anonymous endpoint and neutralises repeated-request abuse, while |
| 518 |
// still letting used/unused classification and page scoping self-correct |
| 519 |
// quickly (hourly by default) as pages change — the old week-long |
| 520 |
// write-once meant a font that started or stopped being used wasn't |
| 521 |
// reflected for days. |
| 522 |
$throttle = (int) apply_filters( 'easyfonts_beacon_throttle_hours', 1 ); |
| 523 |
|
| 524 |
if ( $throttle > 0 && $usage->has_fresh_decision( $route, (string) $device, $throttle ) ) { |
| 525 |
return new WP_REST_Response( array( 'ok' => true, 'cached' => true ), 200 ); |
| 526 |
} |
| 527 |
|
| 528 |
// Hard caps on payload size (defence against oversized public payloads). |
| 529 |
$cap = 200; |
| 530 |
$rendered = is_array( $params['rendered'] ?? null ) ? array_slice( $params['rendered'], 0, $cap ) : array(); |
| 531 |
$preload = is_array( $params['preload'] ?? null ) ? array_slice( $params['preload'], 0, $cap ) : array(); |
| 532 |
$unload = is_array( $params['unload'] ?? null ) ? array_slice( $params['unload'], 0, $cap ) : array(); |
| 533 |
|
| 534 |
// Mark that a real-browser beacon just reported in. The admin polls this |
| 535 |
// after opening the optimize iframe to confirm the X-ray actually ran |
| 536 |
// (vs. being blocked by X-Frame-Options / CSP). |
| 537 |
update_option( 'easyfonts_last_beacon', time(), false ); |
| 538 |
|
| 539 |
// Record observed usage. |
| 540 |
$records = array(); |
| 541 |
|
| 542 |
foreach ( $rendered as $r ) { |
| 543 |
if ( empty( $r['family'] ) ) { |
| 544 |
continue; |
| 545 |
} |
| 546 |
|
| 547 |
$records[] = array( |
| 548 |
'family' => $r['family'], |
| 549 |
'weight' => $r['weight'] ?? '400', |
| 550 |
'style' => $r['style'] ?? 'normal', |
| 551 |
'origin' => 'beacon', |
| 552 |
'rendered' => 1, |
| 553 |
'above_fold' => ! empty( $r['above_fold'] ) ? 1 : 0, |
| 554 |
); |
| 555 |
} |
| 556 |
|
| 557 |
// Also record what loaded but never rendered, as beacon-origin rows with |
| 558 |
// rendered = 0. This is what lets page scoping tell a CONFIRMED-unused |
| 559 |
// font (drop it from this page) apart from a font that simply hasn't been |
| 560 |
// measured yet (keep it) — so a newly added/replaced font is never |
| 561 |
// wrongly scoped out before the beacon has judged it. |
| 562 |
foreach ( $unload as $u ) { |
| 563 |
if ( empty( $u['family'] ) ) { |
| 564 |
continue; |
| 565 |
} |
| 566 |
|
| 567 |
$records[] = array( |
| 568 |
'family' => $u['family'], |
| 569 |
'weight' => $u['weight'] ?? '400', |
| 570 |
'style' => $u['style'] ?? 'normal', |
| 571 |
'origin' => 'beacon', |
| 572 |
'rendered' => 0, |
| 573 |
'above_fold' => 0, |
| 574 |
); |
| 575 |
} |
| 576 |
|
| 577 |
if ( ! empty( $records ) ) { |
| 578 |
$usage->record( $route, url_to_postid( home_url( $route ) ), $records ); |
| 579 |
|
| 580 |
// Refresh the uniformly-above-the-fold family cache from the updated |
| 581 |
// usage data. Runs only when a beacon is accepted (throttled), so the |
| 582 |
// cost is a single grouped scan of the capped usage table. |
| 583 |
$usage->recompute_global_preload(); |
| 584 |
} |
| 585 |
|
| 586 |
// Persist the raw beacon measurement for this route/device. Page scoping |
| 587 |
// and preload are driven from the recorded usage above (family-level, |
| 588 |
// self-correcting); this row anchors the per-route throttle (updated_at) |
| 589 |
// and keeps the raw above-the-fold/unrendered snapshot for diagnostics. |
| 590 |
$usage->store_decision( $route, (string) $device, $this->clean_variants( $preload ), $this->clean_variants( $unload ) ); |
| 591 |
|
| 592 |
return new WP_REST_Response( array( 'ok' => true ), 200 ); |
| 593 |
} |
| 594 |
|
| 595 |
/** |
| 596 |
* Sanitise a list of {family,weight,style} entries. |
| 597 |
* |
| 598 |
* @param array<int,mixed> $list List. |
| 599 |
* @return array<int,array{family:string,weight:string,style:string}> |
| 600 |
*/ |
| 601 |
private function clean_variants( array $list ): array { |
| 602 |
$out = array(); |
| 603 |
|
| 604 |
foreach ( $list as $item ) { |
| 605 |
if ( empty( $item['family'] ) ) { |
| 606 |
continue; |
| 607 |
} |
| 608 |
|
| 609 |
$out[] = array( |
| 610 |
'family' => sanitize_text_field( $item['family'] ), |
| 611 |
'weight' => sanitize_text_field( (string) ( $item['weight'] ?? '400' ) ), |
| 612 |
'style' => sanitize_text_field( (string) ( $item['style'] ?? 'normal' ) ), |
| 613 |
); |
| 614 |
} |
| 615 |
|
| 616 |
return $out; |
| 617 |
} |
| 618 |
|
| 619 |
/** |
| 620 |
* POST /async-fonts — record Google/Bunny CSS URLs that were injected by |
| 621 |
* JavaScript at runtime (caught client-side by async-blocker.js) so the next |
| 622 |
* server render can self-host them. Nonce-checked, provider-validated, and |
| 623 |
* capped. Public input only ever queues *provider* CSS URLs for hosting; it |
| 624 |
* cannot write arbitrary data or flip any persistent setting. |
| 625 |
* |
| 626 |
* @param WP_REST_Request $request Request. |
| 627 |
* @return WP_REST_Response |
| 628 |
*/ |
| 629 |
public function async_fonts( WP_REST_Request $request ): WP_REST_Response { |
| 630 |
if ( ! $this->public_request_authorized( $request ) ) { |
| 631 |
return new WP_REST_Response( array( 'ok' => false ), 403 ); |
| 632 |
} |
| 633 |
|
| 634 |
if ( ! Settings::get( 'enabled', 1 ) || ! Settings::get( 'async_blocker', 0 ) ) { |
| 635 |
return new WP_REST_Response( array( 'ok' => false ), 403 ); |
| 636 |
} |
| 637 |
|
| 638 |
$params = (array) $request->get_json_params(); |
| 639 |
$urls = is_array( $params['urls'] ?? null ) ? array_slice( $params['urls'], 0, 25 ) : array(); |
| 640 |
|
| 641 |
$stored = (array) get_option( 'easyfonts_async_urls', array() ); |
| 642 |
$before = count( $stored ); |
| 643 |
|
| 644 |
foreach ( $urls as $raw ) { |
| 645 |
$url = \EasyFonts\Detect\Providers::normalize_url( (string) $raw ); |
| 646 |
|
| 647 |
// Only accept genuine provider stylesheet URLs. |
| 648 |
if ( ! \EasyFonts\Detect\Providers::is_css_url( $url ) ) { |
| 649 |
continue; |
| 650 |
} |
| 651 |
|
| 652 |
$url = esc_url_raw( $url ); |
| 653 |
|
| 654 |
if ( '' !== $url && ! in_array( $url, $stored, true ) ) { |
| 655 |
$stored[] = $url; |
| 656 |
} |
| 657 |
} |
| 658 |
|
| 659 |
// Keep the queue bounded. |
| 660 |
if ( count( $stored ) > 50 ) { |
| 661 |
$stored = array_slice( $stored, -50 ); |
| 662 |
} |
| 663 |
|
| 664 |
if ( count( $stored ) !== $before ) { |
| 665 |
update_option( 'easyfonts_async_urls', array_values( $stored ), false ); |
| 666 |
|
| 667 |
// New runtime fonts discovered → invalidate so the next render hosts |
| 668 |
// them. Rate-limited: this is public input, and every bump drops the |
| 669 |
// CSS fetch cache site-wide, so crafted unique URLs must not be able |
| 670 |
// to thrash it. |
| 671 |
if ( ! get_transient( 'easyfonts_async_bump_lock' ) ) { |
| 672 |
/** |
| 673 |
* Minimum seconds between async-endpoint cache-buster bumps. |
| 674 |
* |
| 675 |
* @param int $interval |
| 676 |
*/ |
| 677 |
$interval = max( 60, (int) apply_filters( 'easyfonts_async_bump_interval', 5 * MINUTE_IN_SECONDS ) ); |
| 678 |
|
| 679 |
set_transient( 'easyfonts_async_bump_lock', 1, $interval ); |
| 680 |
Settings::bump_buster(); |
| 681 |
} |
| 682 |
} |
| 683 |
|
| 684 |
return new WP_REST_Response( array( 'ok' => true, 'queued' => count( $stored ) ), 200 ); |
| 685 |
} |
| 686 |
|
| 687 |
/** |
| 688 |
* GET /export — return the current settings as a portable JSON payload. |
| 689 |
* |
| 690 |
* @return WP_REST_Response |
| 691 |
*/ |
| 692 |
public function export_settings(): WP_REST_Response { |
| 693 |
return new WP_REST_Response( |
| 694 |
array( |
| 695 |
'_type' => 'easyfonts-settings', |
| 696 |
'_version' => EASYFONTS_VERSION, |
| 697 |
'settings' => Settings::all(), |
| 698 |
), |
| 699 |
200 |
| 700 |
); |
| 701 |
} |
| 702 |
|
| 703 |
/** |
| 704 |
* POST /import — replace settings from an exported payload. Values are run |
| 705 |
* through the same validation as the normal save path (no raw write). |
| 706 |
* |
| 707 |
* @param WP_REST_Request $request Request. |
| 708 |
* @return WP_REST_Response |
| 709 |
*/ |
| 710 |
public function import_settings( WP_REST_Request $request ): WP_REST_Response { |
| 711 |
$body = (array) $request->get_json_params(); |
| 712 |
|
| 713 |
// Accept either the wrapped export ({settings:{…}}) or a bare object. |
| 714 |
$incoming = isset( $body['settings'] ) && is_array( $body['settings'] ) ? $body['settings'] : $body; |
| 715 |
|
| 716 |
if ( empty( $incoming ) ) { |
| 717 |
return new WP_REST_Response( array( 'ok' => false, 'error' => 'empty' ), 400 ); |
| 718 |
} |
| 719 |
|
| 720 |
$defaults = Migrator::default_settings(); |
| 721 |
$current = Settings::all(); |
| 722 |
|
| 723 |
// Booleans. |
| 724 |
foreach ( array( 'enabled', 'auto_config', 'strip_hints', 'smart_preload', 'metric_fallbacks', 'beacon', 'inline_css', 'async_blocker', 'remove_data_on_uninstall' ) as $key ) { |
| 725 |
if ( array_key_exists( $key, $incoming ) ) { |
| 726 |
$current[ $key ] = $incoming[ $key ] ? 1 : 0; |
| 727 |
} |
| 728 |
} |
| 729 |
|
| 730 |
// font-display. |
| 731 |
if ( isset( $incoming['font_display'] ) ) { |
| 732 |
$allowed = array( 'swap', 'fallback', 'optional', 'block', 'auto', 'none' ); |
| 733 |
$val = sanitize_text_field( (string) $incoming['font_display'] ); |
| 734 |
$current['font_display'] = in_array( $val, $allowed, true ) ? $val : 'swap'; |
| 735 |
} |
| 736 |
|
| 737 |
// Subsets / excluded URLs (arrays of strings). |
| 738 |
foreach ( array( 'subsets', 'excluded_urls' ) as $key ) { |
| 739 |
if ( isset( $incoming[ $key ] ) && is_array( $incoming[ $key ] ) ) { |
| 740 |
$current[ $key ] = array_values( array_filter( array_map( 'sanitize_text_field', $incoming[ $key ] ) ) ); |
| 741 |
} |
| 742 |
} |
| 743 |
|
| 744 |
// CDN URL. |
| 745 |
if ( array_key_exists( 'cdn_url', $incoming ) ) { |
| 746 |
$cdn = trim( (string) $incoming['cdn_url'] ); |
| 747 |
|
| 748 |
if ( '' !== $cdn && 0 === strpos( $cdn, '//' ) ) { |
| 749 |
$cdn = ( is_ssl() ? 'https:' : 'http:' ) . $cdn; |
| 750 |
} |
| 751 |
|
| 752 |
$current['cdn_url'] = ( '' !== $cdn && preg_match( '#^https?://#i', $cdn ) ) |
| 753 |
? untrailingslashit( esc_url_raw( $cdn ) ) |
| 754 |
: ''; |
| 755 |
} |
| 756 |
|
| 757 |
// Never import a foreign detector set; keep what this site learned. |
| 758 |
$current['detectors'] = (array) ( $current['detectors'] ?? $defaults['detectors'] ); |
| 759 |
|
| 760 |
Settings::save( $current ); |
| 761 |
Settings::bump_buster(); |
| 762 |
|
| 763 |
return new WP_REST_Response( array( 'ok' => true, 'settings' => Settings::all() ), 200 ); |
| 764 |
} |
| 765 |
} |