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
easyfonts / src / Admin / RestApi.php

RestApi.php in EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant 2.0.4, at src/Admin/RestApi.php

804 lines 25.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 // After core's cookie check (priority 100).
33 add_filter( 'rest_authentication_errors', array( $this, 'accept_cached_page_token' ), 101 );
34 }
35
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 /**
74 * Register routes.
75 */
76 public function register(): void {
77 $admin = array( $this, 'can_manage' );
78
79 register_rest_route(
80 self::NS,
81 '/settings',
82 array(
83 array(
84 'methods' => 'GET',
85 'callback' => array( $this, 'get_settings' ),
86 'permission_callback' => $admin,
87 ),
88 array(
89 'methods' => 'POST',
90 'callback' => array( $this, 'save_settings' ),
91 'permission_callback' => $admin,
92 ),
93 )
94 );
95
96 register_rest_route( self::NS, '/fonts', array(
97 'methods' => 'GET',
98 'callback' => array( $this, 'get_fonts' ),
99 'permission_callback' => $admin,
100 ) );
101
102 register_rest_route( self::NS, '/font/toggle', array(
103 'methods' => 'POST',
104 'callback' => array( $this, 'toggle_font' ),
105 'permission_callback' => $admin,
106 ) );
107
108 register_rest_route( self::NS, '/fonts/bulk', array(
109 'methods' => 'POST',
110 'callback' => array( $this, 'bulk_toggle' ),
111 'permission_callback' => $admin,
112 ) );
113
114 register_rest_route( self::NS, '/optimize', array(
115 'methods' => 'POST',
116 'callback' => array( $this, 'optimize' ),
117 'permission_callback' => $admin,
118 ) );
119
120 register_rest_route( self::NS, '/usage', array(
121 'methods' => 'GET',
122 'callback' => array( $this, 'get_usage' ),
123 'permission_callback' => $admin,
124 ) );
125
126 register_rest_route( self::NS, '/stats', array(
127 'methods' => 'GET',
128 'callback' => array( $this, 'get_stats' ),
129 'permission_callback' => $admin,
130 ) );
131
132 register_rest_route( self::NS, '/purge', array(
133 'methods' => 'POST',
134 'callback' => array( $this, 'purge' ),
135 'permission_callback' => $admin,
136 ) );
137
138 register_rest_route( self::NS, '/repair', array(
139 'methods' => 'POST',
140 'callback' => array( $this, 'repair' ),
141 'permission_callback' => $admin,
142 ) );
143
144 register_rest_route( self::NS, '/diagnostics', array(
145 'methods' => 'GET',
146 'callback' => array( $this, 'diagnostics' ),
147 'permission_callback' => $admin,
148 ) );
149
150 // Public beacon ingest (nonce-checked in handler).
151 register_rest_route( self::NS, '/beacon', array(
152 'methods' => 'POST',
153 'callback' => array( $this, 'beacon' ),
154 'permission_callback' => '__return_true',
155 ) );
156
157 // Public async-font ingest: records Google/Bunny CSS URLs injected by JS
158 // at runtime so the next render can self-host them (nonce-checked).
159 register_rest_route( self::NS, '/async-fonts', array(
160 'methods' => 'POST',
161 'callback' => array( $this, 'async_fonts' ),
162 'permission_callback' => '__return_true',
163 ) );
164
165 // Settings export / import (admin only).
166 register_rest_route( self::NS, '/export', array(
167 'methods' => 'GET',
168 'callback' => array( $this, 'export_settings' ),
169 'permission_callback' => $admin,
170 ) );
171
172 register_rest_route( self::NS, '/import', array(
173 'methods' => 'POST',
174 'callback' => array( $this, 'import_settings' ),
175 'permission_callback' => $admin,
176 ) );
177 }
178
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 /**
203 * Capability gate.
204 *
205 * @return bool
206 */
207 public function can_manage(): bool {
208 return current_user_can( 'manage_options' );
209 }
210
211 /**
212 * GET /settings
213 *
214 * @return WP_REST_Response
215 */
216 public function get_settings(): WP_REST_Response {
217 return new WP_REST_Response( Settings::all(), 200 );
218 }
219
220 /**
221 * POST /settings
222 *
223 * @param WP_REST_Request $request Request.
224 * @return WP_REST_Response
225 */
226 public function save_settings( WP_REST_Request $request ): WP_REST_Response {
227 $input = (array) $request->get_json_params();
228 $current = Settings::all();
229
230 $bools = array( 'enabled', 'auto_config', 'strip_hints', 'smart_preload', 'metric_fallbacks', 'beacon', 'inline_css', 'async_blocker', 'remove_data_on_uninstall' );
231
232 foreach ( $bools as $key ) {
233 if ( array_key_exists( $key, $input ) ) {
234 $current[ $key ] = $input[ $key ] ? 1 : 0;
235 }
236 }
237
238 if ( isset( $input['font_display'] ) ) {
239 $allowed = array( 'swap', 'fallback', 'optional', 'block', 'auto', 'none' );
240 $val = sanitize_text_field( $input['font_display'] );
241 $current['font_display'] = in_array( $val, $allowed, true ) ? $val : 'swap';
242 }
243
244 if ( array_key_exists( 'cdn_url', $input ) ) {
245 $cdn = trim( (string) $input['cdn_url'] );
246
247 // Accept protocol-relative; require http(s); store without trailing slash.
248 if ( '' === $cdn ) {
249 $current['cdn_url'] = '';
250 } else {
251 if ( 0 === strpos( $cdn, '//' ) ) {
252 $cdn = ( is_ssl() ? 'https:' : 'http:' ) . $cdn;
253 }
254
255 $current['cdn_url'] = preg_match( '#^https?://#i', $cdn )
256 ? untrailingslashit( esc_url_raw( $cdn ) )
257 : '';
258 }
259 }
260
261 if ( isset( $input['subsets'] ) && is_array( $input['subsets'] ) ) {
262 $current['subsets'] = array_values( array_map( 'sanitize_text_field', $input['subsets'] ) );
263 }
264
265 if ( isset( $input['excluded_urls'] ) && is_array( $input['excluded_urls'] ) ) {
266 $current['excluded_urls'] = array_values( array_filter( array_map( 'sanitize_text_field', $input['excluded_urls'] ) ) );
267 }
268
269 Settings::save( $current );
270 Settings::bump_buster();
271
272 return new WP_REST_Response( Settings::all(), 200 );
273 }
274
275 /**
276 * GET /fonts
277 *
278 * @return WP_REST_Response
279 */
280 public function get_fonts(): WP_REST_Response {
281 $rendered = ( new UsageTracker() )->rendered_set();
282
283 $grouped = ( new Registry() )->grouped_with_usage(
284 $rendered['variants'],
285 $rendered['families']
286 );
287
288 return new WP_REST_Response( $grouped, 200 );
289 }
290
291 /**
292 * POST /font/toggle — enable/disable or set preload on a variant or family.
293 *
294 * Body: { field: 'enabled'|'preloaded', value: bool, family: string,
295 * weight?: string, style?: string }
296 * Omitting weight/style targets the whole family.
297 *
298 * @param WP_REST_Request $request Request.
299 * @return WP_REST_Response
300 */
301 public function toggle_font( WP_REST_Request $request ): WP_REST_Response {
302 $params = (array) $request->get_json_params();
303
304 $field = sanitize_text_field( $params['field'] ?? '' );
305 $family = sanitize_text_field( $params['family'] ?? '' );
306 $weight = isset( $params['weight'] ) ? sanitize_text_field( (string) $params['weight'] ) : '';
307 $style = isset( $params['style'] ) ? sanitize_text_field( (string) $params['style'] ) : '';
308 $value = ! empty( $params['value'] );
309
310 $column = 'enabled' === $field ? 'is_enabled' : ( 'preloaded' === $field ? 'is_preloaded' : '' );
311
312 if ( '' === $column || '' === $family ) {
313 return new WP_REST_Response( array( 'ok' => false, 'error' => 'bad_request' ), 400 );
314 }
315
316 ( new Registry() )->set_flag( $column, $value, $family, $weight, $style );
317
318 // Enabling/disabling changes which fonts the stylesheet contains, so the
319 // next render must rebuild it; bump the buster to invalidate caches.
320 if ( 'is_enabled' === $column ) {
321 Settings::bump_buster();
322 }
323
324 return new WP_REST_Response( array( 'ok' => true ), 200 );
325 }
326
327 /**
328 * POST /fonts/bulk — enable/disable (or preload) every font in a scope at
329 * once. Body: { field:'enabled'|'preloaded', value:bool, scope:'used'|'unused'|'all' }
330 *
331 * @param WP_REST_Request $request Request.
332 * @return WP_REST_Response
333 */
334 public function bulk_toggle( WP_REST_Request $request ): WP_REST_Response {
335 $params = (array) $request->get_json_params();
336
337 $field = 'preloaded' === ( $params['field'] ?? '' ) ? 'is_preloaded' : 'is_enabled';
338 $value = ! empty( $params['value'] );
339 $scope = in_array( $params['scope'] ?? 'all', array( 'used', 'unused', 'all' ), true ) ? $params['scope'] : 'all';
340
341 $registry = new Registry();
342
343 if ( 'all' === $scope ) {
344 $registry->set_flag_all( $field, $value );
345 } else {
346 $rendered = ( new UsageTracker() )->rendered_set();
347 $grouped = $registry->grouped_with_usage( $rendered['variants'], $rendered['families'] );
348
349 foreach ( $grouped as $family ) {
350 $is_used = ! empty( $family['used'] );
351
352 if ( ( 'used' === $scope && $is_used ) || ( 'unused' === $scope && ! $is_used ) ) {
353 $registry->set_flag( $field, $value, $family['family'] );
354 }
355 }
356 }
357
358 if ( 'is_enabled' === $field ) {
359 Settings::bump_buster();
360 }
361
362 return new WP_REST_Response( array( 'ok' => true ), 200 );
363 }
364
365 /**
366 * POST /optimize — prime optimisation now via a cache-busting loopback to
367 * the front page, so the user doesn't have to visit it manually.
368 *
369 * @return WP_REST_Response
370 */
371 public function optimize(): WP_REST_Response {
372 $targets = apply_filters(
373 'easyfonts_optimize_urls',
374 array( home_url( '/' ) )
375 );
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
386 $ok = 0;
387
388 foreach ( (array) $targets as $target ) {
389 // Cache-busting param forces a fresh PHP render (bypasses page cache).
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 );
398
399 $response = wp_remote_get(
400 $url,
401 array(
402 'timeout' => 30,
403 /**
404 * Verify SSL on the loopback self-request. True by default;
405 * filter to false only for self-signed/staging certs.
406 *
407 * @param bool $verify
408 */
409 'sslverify' => (bool) apply_filters( 'easyfonts_loopback_sslverify', true ),
410 )
411 );
412
413 if ( ! is_wp_error( $response ) && (int) wp_remote_retrieve_response_code( $response ) < 400 ) {
414 $ok++;
415 }
416 }
417
418 $stats = ( new Registry() )->stats();
419
420 return new WP_REST_Response(
421 array(
422 'ok' => $ok > 0,
423 'probed' => $ok,
424 'families' => $stats['families'],
425 'variants' => $stats['variants'],
426 // The admin loads this in a hidden iframe to run the real-browser
427 // X-ray (beacon), which measures rendered fonts + subsets.
428 'probe_url' => add_query_arg( 'easyfonts_probe', (string) time(), home_url( '/' ) ),
429 // Snapshot of the last-beacon time BEFORE the X-ray; the client
430 // polls /stats and watches for this to advance, which confirms the
431 // iframe actually rendered (and wasn't blocked by X-Frame-Options).
432 'beacon_before' => (int) get_option( 'easyfonts_last_beacon', 0 ),
433 ),
434 200
435 );
436 }
437
438 /**
439 * GET /usage
440 *
441 * @return WP_REST_Response
442 */
443 public function get_usage(): WP_REST_Response {
444 return new WP_REST_Response( ( new UsageTracker() )->summary(), 200 );
445 }
446
447 /**
448 * GET /stats
449 *
450 * @return WP_REST_Response
451 */
452 public function get_stats(): WP_REST_Response {
453 $registry = new Registry();
454 $storage = new Storage();
455 $stats = $registry->stats();
456
457 return new WP_REST_Response(
458 array(
459 'families' => $stats['families'],
460 'variants' => $stats['variants'],
461 'hosted_kb' => (int) round( $stats['bytes'] / 1024 ),
462 'cache_kb' => (int) round( $storage->total_size() / 1024 ),
463 'detectors' => (array) Settings::get( 'detectors', array() ),
464 'last_buster' => Settings::buster(),
465 'last_beacon' => (int) get_option( 'easyfonts_last_beacon', 0 ),
466 ),
467 200
468 );
469 }
470
471 /**
472 * POST /purge — clear cache + tables, keep settings.
473 *
474 * @return WP_REST_Response
475 */
476 public function purge(): WP_REST_Response {
477 ( new Storage() )->purge();
478 ( new Registry() )->truncate();
479 ( new UsageTracker() )->truncate();
480
481 delete_option( 'easyfonts_processed_local' );
482 delete_option( 'easyfonts_processed_external' );
483 delete_option( 'easyfonts_warm_key' ); // Rotate the loopback secret.
484
485 $settings = Settings::all();
486 $settings['detectors'] = array();
487 Settings::save( $settings );
488 Settings::bump_buster();
489
490 return new WP_REST_Response( array( 'ok' => true ), 200 );
491 }
492
493 /**
494 * POST /repair — drop + recreate the tables (fixes stale/legacy schema),
495 * then clear cache + learned state so the next page view rebuilds cleanly.
496 *
497 * @return WP_REST_Response
498 */
499 public function repair(): WP_REST_Response {
500 ( new Migrator() )->repair();
501 ( new Storage() )->purge();
502
503 delete_option( 'easyfonts_processed_local' );
504 delete_option( 'easyfonts_processed_external' );
505 delete_option( 'easyfonts_warm_key' ); // Rotate the loopback secret.
506 delete_transient( 'easyfonts_schema_checked' );
507
508 $settings = Settings::all();
509 $settings['detectors'] = array();
510 Settings::save( $settings );
511 Settings::bump_buster();
512
513 return new WP_REST_Response(
514 array(
515 'ok' => true,
516 'tables' => ( new Migrator() )->status(),
517 ),
518 200
519 );
520 }
521
522 /**
523 * GET /diagnostics
524 *
525 * @return WP_REST_Response
526 */
527 public function diagnostics(): WP_REST_Response {
528 return new WP_REST_Response( Diagnostics::collect(), 200 );
529 }
530
531 /**
532 * POST /beacon — ingest real-render decisions.
533 *
534 * @param WP_REST_Request $request Request.
535 * @return WP_REST_Response
536 */
537 public function beacon( WP_REST_Request $request ): WP_REST_Response {
538 if ( ! $this->public_request_authorized( $request ) ) {
539 return new WP_REST_Response( array( 'ok' => false ), 403 );
540 }
541
542 $params = (array) $request->get_json_params();
543 $route = sanitize_text_field( $params['route'] ?? '/' );
544 $device = in_array( $params['device'] ?? '', array( 'mobile', 'desktop' ), true ) ? $params['device'] : 'any';
545
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 );
551
552 $usage = new UsageTracker();
553
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 ) ) {
564 return new WP_REST_Response( array( 'ok' => true, 'cached' => true ), 200 );
565 }
566
567 // Hard caps on payload size (defence against oversized public payloads).
568 $cap = 200;
569 $rendered = is_array( $params['rendered'] ?? null ) ? array_slice( $params['rendered'], 0, $cap ) : array();
570 $preload = is_array( $params['preload'] ?? null ) ? array_slice( $params['preload'], 0, $cap ) : array();
571 $unload = is_array( $params['unload'] ?? null ) ? array_slice( $params['unload'], 0, $cap ) : array();
572
573 // Mark that a real-browser beacon just reported in. The admin polls this
574 // after opening the optimize iframe to confirm the X-ray actually ran
575 // (vs. being blocked by X-Frame-Options / CSP).
576 update_option( 'easyfonts_last_beacon', time(), false );
577
578 // Record observed usage.
579 $records = array();
580
581 foreach ( $rendered as $r ) {
582 if ( empty( $r['family'] ) ) {
583 continue;
584 }
585
586 $records[] = array(
587 'family' => $r['family'],
588 'weight' => $r['weight'] ?? '400',
589 'style' => $r['style'] ?? 'normal',
590 'origin' => 'beacon',
591 'rendered' => 1,
592 'above_fold' => ! empty( $r['above_fold'] ) ? 1 : 0,
593 );
594 }
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
616 if ( ! empty( $records ) ) {
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();
623 }
624
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.
629 $usage->store_decision( $route, (string) $device, $this->clean_variants( $preload ), $this->clean_variants( $unload ) );
630
631 return new WP_REST_Response( array( 'ok' => true ), 200 );
632 }
633
634 /**
635 * Sanitise a list of {family,weight,style} entries.
636 *
637 * @param array<int,mixed> $list List.
638 * @return array<int,array{family:string,weight:string,style:string}>
639 */
640 private function clean_variants( array $list ): array {
641 $out = array();
642
643 foreach ( $list as $item ) {
644 if ( empty( $item['family'] ) ) {
645 continue;
646 }
647
648 $out[] = array(
649 'family' => sanitize_text_field( $item['family'] ),
650 'weight' => sanitize_text_field( (string) ( $item['weight'] ?? '400' ) ),
651 'style' => sanitize_text_field( (string) ( $item['style'] ?? 'normal' ) ),
652 );
653 }
654
655 return $out;
656 }
657
658 /**
659 * POST /async-fonts — record Google/Bunny CSS URLs that were injected by
660 * JavaScript at runtime (caught client-side by async-blocker.js) so the next
661 * server render can self-host them. Nonce-checked, provider-validated, and
662 * capped. Public input only ever queues *provider* CSS URLs for hosting; it
663 * cannot write arbitrary data or flip any persistent setting.
664 *
665 * @param WP_REST_Request $request Request.
666 * @return WP_REST_Response
667 */
668 public function async_fonts( WP_REST_Request $request ): WP_REST_Response {
669 if ( ! $this->public_request_authorized( $request ) ) {
670 return new WP_REST_Response( array( 'ok' => false ), 403 );
671 }
672
673 if ( ! Settings::get( 'enabled', 1 ) || ! Settings::get( 'async_blocker', 0 ) ) {
674 return new WP_REST_Response( array( 'ok' => false ), 403 );
675 }
676
677 $params = (array) $request->get_json_params();
678 $urls = is_array( $params['urls'] ?? null ) ? array_slice( $params['urls'], 0, 25 ) : array();
679
680 $stored = (array) get_option( 'easyfonts_async_urls', array() );
681 $before = count( $stored );
682
683 foreach ( $urls as $raw ) {
684 $url = \EasyFonts\Detect\Providers::normalize_url( (string) $raw );
685
686 // Only accept genuine provider stylesheet URLs.
687 if ( ! \EasyFonts\Detect\Providers::is_css_url( $url ) ) {
688 continue;
689 }
690
691 $url = esc_url_raw( $url );
692
693 if ( '' !== $url && ! in_array( $url, $stored, true ) ) {
694 $stored[] = $url;
695 }
696 }
697
698 // Keep the queue bounded.
699 if ( count( $stored ) > 50 ) {
700 $stored = array_slice( $stored, -50 );
701 }
702
703 if ( count( $stored ) !== $before ) {
704 update_option( 'easyfonts_async_urls', array_values( $stored ), false );
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 }
721 }
722
723 return new WP_REST_Response( array( 'ok' => true, 'queued' => count( $stored ) ), 200 );
724 }
725
726 /**
727 * GET /export — return the current settings as a portable JSON payload.
728 *
729 * @return WP_REST_Response
730 */
731 public function export_settings(): WP_REST_Response {
732 return new WP_REST_Response(
733 array(
734 '_type' => 'easyfonts-settings',
735 '_version' => EASYFONTS_VERSION,
736 'settings' => Settings::all(),
737 ),
738 200
739 );
740 }
741
742 /**
743 * POST /import — replace settings from an exported payload. Values are run
744 * through the same validation as the normal save path (no raw write).
745 *
746 * @param WP_REST_Request $request Request.
747 * @return WP_REST_Response
748 */
749 public function import_settings( WP_REST_Request $request ): WP_REST_Response {
750 $body = (array) $request->get_json_params();
751
752 // Accept either the wrapped export ({settings:{…}}) or a bare object.
753 $incoming = isset( $body['settings'] ) && is_array( $body['settings'] ) ? $body['settings'] : $body;
754
755 if ( empty( $incoming ) ) {
756 return new WP_REST_Response( array( 'ok' => false, 'error' => 'empty' ), 400 );
757 }
758
759 $defaults = Migrator::default_settings();
760 $current = Settings::all();
761
762 // Booleans.
763 foreach ( array( 'enabled', 'auto_config', 'strip_hints', 'smart_preload', 'metric_fallbacks', 'beacon', 'inline_css', 'async_blocker', 'remove_data_on_uninstall' ) as $key ) {
764 if ( array_key_exists( $key, $incoming ) ) {
765 $current[ $key ] = $incoming[ $key ] ? 1 : 0;
766 }
767 }
768
769 // font-display.
770 if ( isset( $incoming['font_display'] ) ) {
771 $allowed = array( 'swap', 'fallback', 'optional', 'block', 'auto', 'none' );
772 $val = sanitize_text_field( (string) $incoming['font_display'] );
773 $current['font_display'] = in_array( $val, $allowed, true ) ? $val : 'swap';
774 }
775
776 // Subsets / excluded URLs (arrays of strings).
777 foreach ( array( 'subsets', 'excluded_urls' ) as $key ) {
778 if ( isset( $incoming[ $key ] ) && is_array( $incoming[ $key ] ) ) {
779 $current[ $key ] = array_values( array_filter( array_map( 'sanitize_text_field', $incoming[ $key ] ) ) );
780 }
781 }
782
783 // CDN URL.
784 if ( array_key_exists( 'cdn_url', $incoming ) ) {
785 $cdn = trim( (string) $incoming['cdn_url'] );
786
787 if ( '' !== $cdn && 0 === strpos( $cdn, '//' ) ) {
788 $cdn = ( is_ssl() ? 'https:' : 'http:' ) . $cdn;
789 }
790
791 $current['cdn_url'] = ( '' !== $cdn && preg_match( '#^https?://#i', $cdn ) )
792 ? untrailingslashit( esc_url_raw( $cdn ) )
793 : '';
794 }
795
796 // Never import a foreign detector set; keep what this site learned.
797 $current['detectors'] = (array) ( $current['detectors'] ?? $defaults['detectors'] );
798
799 Settings::save( $current );
800 Settings::bump_buster();
801
802 return new WP_REST_Response( array( 'ok' => true, 'settings' => Settings::all() ), 200 );
803 }
804 }