PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.1
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.1
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.1, at src/Admin/RestApi.php

726 lines 21.9 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 }
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 * Capability gate.
142 *
143 * @return bool
144 */
145 public function can_manage(): bool {
146 return current_user_can( 'manage_options' );
147 }
148
149 /**
150 * GET /settings
151 *
152 * @return WP_REST_Response
153 */
154 public function get_settings(): WP_REST_Response {
155 return new WP_REST_Response( Settings::all(), 200 );
156 }
157
158 /**
159 * POST /settings
160 *
161 * @param WP_REST_Request $request Request.
162 * @return WP_REST_Response
163 */
164 public function save_settings( WP_REST_Request $request ): WP_REST_Response {
165 $input = (array) $request->get_json_params();
166 $current = Settings::all();
167
168 $bools = array( 'enabled', 'auto_config', 'strip_hints', 'smart_preload', 'metric_fallbacks', 'beacon', 'inline_css', 'async_blocker', 'remove_data_on_uninstall' );
169
170 foreach ( $bools as $key ) {
171 if ( array_key_exists( $key, $input ) ) {
172 $current[ $key ] = $input[ $key ] ? 1 : 0;
173 }
174 }
175
176 if ( isset( $input['font_display'] ) ) {
177 $allowed = array( 'swap', 'fallback', 'optional', 'block', 'auto', 'none' );
178 $val = sanitize_text_field( $input['font_display'] );
179 $current['font_display'] = in_array( $val, $allowed, true ) ? $val : 'swap';
180 }
181
182 if ( array_key_exists( 'cdn_url', $input ) ) {
183 $cdn = trim( (string) $input['cdn_url'] );
184
185 // Accept protocol-relative; require http(s); store without trailing slash.
186 if ( '' === $cdn ) {
187 $current['cdn_url'] = '';
188 } else {
189 if ( 0 === strpos( $cdn, '//' ) ) {
190 $cdn = ( is_ssl() ? 'https:' : 'http:' ) . $cdn;
191 }
192
193 $current['cdn_url'] = preg_match( '#^https?://#i', $cdn )
194 ? untrailingslashit( esc_url_raw( $cdn ) )
195 : '';
196 }
197 }
198
199 if ( isset( $input['subsets'] ) && is_array( $input['subsets'] ) ) {
200 $current['subsets'] = array_values( array_map( 'sanitize_text_field', $input['subsets'] ) );
201 }
202
203 if ( isset( $input['excluded_urls'] ) && is_array( $input['excluded_urls'] ) ) {
204 $current['excluded_urls'] = array_values( array_filter( array_map( 'sanitize_text_field', $input['excluded_urls'] ) ) );
205 }
206
207 Settings::save( $current );
208 Settings::bump_buster();
209
210 return new WP_REST_Response( Settings::all(), 200 );
211 }
212
213 /**
214 * GET /fonts
215 *
216 * @return WP_REST_Response
217 */
218 public function get_fonts(): WP_REST_Response {
219 $rendered = ( new UsageTracker() )->rendered_set();
220
221 $grouped = ( new Registry() )->grouped_with_usage(
222 $rendered['variants'],
223 $rendered['families']
224 );
225
226 return new WP_REST_Response( $grouped, 200 );
227 }
228
229 /**
230 * POST /font/toggle — enable/disable or set preload on a variant or family.
231 *
232 * Body: { field: 'enabled'|'preloaded', value: bool, family: string,
233 * weight?: string, style?: string }
234 * Omitting weight/style targets the whole family.
235 *
236 * @param WP_REST_Request $request Request.
237 * @return WP_REST_Response
238 */
239 public function toggle_font( WP_REST_Request $request ): WP_REST_Response {
240 $params = (array) $request->get_json_params();
241
242 $field = sanitize_text_field( $params['field'] ?? '' );
243 $family = sanitize_text_field( $params['family'] ?? '' );
244 $weight = isset( $params['weight'] ) ? sanitize_text_field( (string) $params['weight'] ) : '';
245 $style = isset( $params['style'] ) ? sanitize_text_field( (string) $params['style'] ) : '';
246 $value = ! empty( $params['value'] );
247
248 $column = 'enabled' === $field ? 'is_enabled' : ( 'preloaded' === $field ? 'is_preloaded' : '' );
249
250 if ( '' === $column || '' === $family ) {
251 return new WP_REST_Response( array( 'ok' => false, 'error' => 'bad_request' ), 400 );
252 }
253
254 ( new Registry() )->set_flag( $column, $value, $family, $weight, $style );
255
256 // Enabling/disabling changes which fonts the stylesheet contains, so the
257 // next render must rebuild it; bump the buster to invalidate caches.
258 if ( 'is_enabled' === $column ) {
259 Settings::bump_buster();
260 }
261
262 return new WP_REST_Response( array( 'ok' => true ), 200 );
263 }
264
265 /**
266 * POST /fonts/bulk — enable/disable (or preload) every font in a scope at
267 * once. Body: { field:'enabled'|'preloaded', value:bool, scope:'used'|'unused'|'all' }
268 *
269 * @param WP_REST_Request $request Request.
270 * @return WP_REST_Response
271 */
272 public function bulk_toggle( WP_REST_Request $request ): WP_REST_Response {
273 $params = (array) $request->get_json_params();
274
275 $field = 'preloaded' === ( $params['field'] ?? '' ) ? 'is_preloaded' : 'is_enabled';
276 $value = ! empty( $params['value'] );
277 $scope = in_array( $params['scope'] ?? 'all', array( 'used', 'unused', 'all' ), true ) ? $params['scope'] : 'all';
278
279 $registry = new Registry();
280
281 if ( 'all' === $scope ) {
282 $registry->set_flag_all( $field, $value );
283 } else {
284 $rendered = ( new UsageTracker() )->rendered_set();
285 $grouped = $registry->grouped_with_usage( $rendered['variants'], $rendered['families'] );
286
287 foreach ( $grouped as $family ) {
288 $is_used = ! empty( $family['used'] );
289
290 if ( ( 'used' === $scope && $is_used ) || ( 'unused' === $scope && ! $is_used ) ) {
291 $registry->set_flag( $field, $value, $family['family'] );
292 }
293 }
294 }
295
296 if ( 'is_enabled' === $field ) {
297 Settings::bump_buster();
298 }
299
300 return new WP_REST_Response( array( 'ok' => true ), 200 );
301 }
302
303 /**
304 * POST /optimize — prime optimisation now via a cache-busting loopback to
305 * the front page, so the user doesn't have to visit it manually.
306 *
307 * @return WP_REST_Response
308 */
309 public function optimize(): WP_REST_Response {
310 $targets = apply_filters(
311 'easyfonts_optimize_urls',
312 array( home_url( '/' ) )
313 );
314
315 $ok = 0;
316
317 foreach ( (array) $targets as $target ) {
318 // Cache-busting param forces a fresh PHP render (bypasses page cache).
319 // The warm key authorises this unauthenticated self-request to download.
320 $url = add_query_arg(
321 array(
322 'easyfonts_probe' => Settings::warm_key(),
323 'efbust' => (string) time(),
324 ),
325 $target
326 );
327
328 $response = wp_remote_get(
329 $url,
330 array(
331 'timeout' => 30,
332 /**
333 * Verify SSL on the loopback self-request. True by default;
334 * filter to false only for self-signed/staging certs.
335 *
336 * @param bool $verify
337 */
338 'sslverify' => (bool) apply_filters( 'easyfonts_loopback_sslverify', true ),
339 )
340 );
341
342 if ( ! is_wp_error( $response ) && (int) wp_remote_retrieve_response_code( $response ) < 400 ) {
343 $ok++;
344 }
345 }
346
347 $stats = ( new Registry() )->stats();
348
349 return new WP_REST_Response(
350 array(
351 'ok' => $ok > 0,
352 'probed' => $ok,
353 'families' => $stats['families'],
354 'variants' => $stats['variants'],
355 // The admin loads this in a hidden iframe to run the real-browser
356 // X-ray (beacon), which measures rendered fonts + subsets.
357 'probe_url' => add_query_arg( 'easyfonts_probe', (string) time(), home_url( '/' ) ),
358 // Snapshot of the last-beacon time BEFORE the X-ray; the client
359 // polls /stats and watches for this to advance, which confirms the
360 // iframe actually rendered (and wasn't blocked by X-Frame-Options).
361 'beacon_before' => (int) get_option( 'easyfonts_last_beacon', 0 ),
362 ),
363 200
364 );
365 }
366
367 /**
368 * GET /usage
369 *
370 * @return WP_REST_Response
371 */
372 public function get_usage(): WP_REST_Response {
373 return new WP_REST_Response( ( new UsageTracker() )->summary(), 200 );
374 }
375
376 /**
377 * GET /stats
378 *
379 * @return WP_REST_Response
380 */
381 public function get_stats(): WP_REST_Response {
382 $registry = new Registry();
383 $storage = new Storage();
384 $stats = $registry->stats();
385
386 return new WP_REST_Response(
387 array(
388 'families' => $stats['families'],
389 'variants' => $stats['variants'],
390 'hosted_kb' => (int) round( $stats['bytes'] / 1024 ),
391 'cache_kb' => (int) round( $storage->total_size() / 1024 ),
392 'detectors' => (array) Settings::get( 'detectors', array() ),
393 'last_buster' => Settings::buster(),
394 'last_beacon' => (int) get_option( 'easyfonts_last_beacon', 0 ),
395 ),
396 200
397 );
398 }
399
400 /**
401 * POST /purge — clear cache + tables, keep settings.
402 *
403 * @return WP_REST_Response
404 */
405 public function purge(): WP_REST_Response {
406 ( new Storage() )->purge();
407 ( new Registry() )->truncate();
408 ( new UsageTracker() )->truncate();
409
410 delete_option( 'easyfonts_processed_local' );
411 delete_option( 'easyfonts_processed_external' );
412
413 $settings = Settings::all();
414 $settings['detectors'] = array();
415 Settings::save( $settings );
416 Settings::bump_buster();
417
418 return new WP_REST_Response( array( 'ok' => true ), 200 );
419 }
420
421 /**
422 * POST /repair — drop + recreate the tables (fixes stale/legacy schema),
423 * then clear cache + learned state so the next page view rebuilds cleanly.
424 *
425 * @return WP_REST_Response
426 */
427 public function repair(): WP_REST_Response {
428 ( new Migrator() )->repair();
429 ( new Storage() )->purge();
430
431 delete_option( 'easyfonts_processed_local' );
432 delete_option( 'easyfonts_processed_external' );
433 delete_transient( 'easyfonts_schema_checked' );
434
435 $settings = Settings::all();
436 $settings['detectors'] = array();
437 Settings::save( $settings );
438 Settings::bump_buster();
439
440 return new WP_REST_Response(
441 array(
442 'ok' => true,
443 'tables' => ( new Migrator() )->status(),
444 ),
445 200
446 );
447 }
448
449 /**
450 * GET /diagnostics
451 *
452 * @return WP_REST_Response
453 */
454 public function diagnostics(): WP_REST_Response {
455 return new WP_REST_Response( Diagnostics::collect(), 200 );
456 }
457
458 /**
459 * POST /beacon — ingest real-render decisions.
460 *
461 * @param WP_REST_Request $request Request.
462 * @return WP_REST_Response
463 */
464 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' ) ) {
472 return new WP_REST_Response( array( 'ok' => false ), 403 );
473 }
474
475 $params = (array) $request->get_json_params();
476 $route = sanitize_text_field( $params['route'] ?? '/' );
477 $device = in_array( $params['device'] ?? '', array( 'mobile', 'desktop' ), true ) ? $params['device'] : 'any';
478
479 // Cap the route length to keep the indexed column sane.
480 $route = '' === $route ? '/' : substr( $route, 0, 480 );
481
482 $usage = new UsageTracker();
483
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 ) ) {
491 return new WP_REST_Response( array( 'ok' => true, 'cached' => true ), 200 );
492 }
493
494 // Hard caps on payload size (defence against oversized public payloads).
495 $cap = 200;
496 $rendered = is_array( $params['rendered'] ?? null ) ? array_slice( $params['rendered'], 0, $cap ) : array();
497 $preload = is_array( $params['preload'] ?? null ) ? array_slice( $params['preload'], 0, $cap ) : array();
498 $unload = is_array( $params['unload'] ?? null ) ? array_slice( $params['unload'], 0, $cap ) : array();
499
500 // Mark that a real-browser beacon just reported in. The admin polls this
501 // after opening the optimize iframe to confirm the X-ray actually ran
502 // (vs. being blocked by X-Frame-Options / CSP).
503 update_option( 'easyfonts_last_beacon', time(), false );
504
505 // Record observed usage.
506 $records = array();
507
508 foreach ( $rendered as $r ) {
509 if ( empty( $r['family'] ) ) {
510 continue;
511 }
512
513 $records[] = array(
514 'family' => $r['family'],
515 'weight' => $r['weight'] ?? '400',
516 'style' => $r['style'] ?? 'normal',
517 'origin' => 'beacon',
518 'rendered' => 1,
519 'above_fold' => ! empty( $r['above_fold'] ) ? 1 : 0,
520 );
521 }
522
523 if ( ! empty( $records ) ) {
524 $usage->record( $route, url_to_postid( home_url( $route ) ), $records );
525 }
526
527 // Store the decision for future preloads/unloads.
528 $usage->store_decision( $route, (string) $device, $this->clean_variants( $preload ), $this->clean_variants( $unload ) );
529
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 return new WP_REST_Response( array( 'ok' => true ), 200 );
562 }
563
564 /**
565 * Sanitise a list of {family,weight,style} entries.
566 *
567 * @param array<int,mixed> $list List.
568 * @return array<int,array{family:string,weight:string,style:string}>
569 */
570 private function clean_variants( array $list ): array {
571 $out = array();
572
573 foreach ( $list as $item ) {
574 if ( empty( $item['family'] ) ) {
575 continue;
576 }
577
578 $out[] = array(
579 'family' => sanitize_text_field( $item['family'] ),
580 'weight' => sanitize_text_field( (string) ( $item['weight'] ?? '400' ) ),
581 'style' => sanitize_text_field( (string) ( $item['style'] ?? 'normal' ) ),
582 );
583 }
584
585 return $out;
586 }
587
588 /**
589 * POST /async-fonts — record Google/Bunny CSS URLs that were injected by
590 * JavaScript at runtime (caught client-side by async-blocker.js) so the next
591 * server render can self-host them. Nonce-checked, provider-validated, and
592 * capped. Public input only ever queues *provider* CSS URLs for hosting; it
593 * cannot write arbitrary data or flip any persistent setting.
594 *
595 * @param WP_REST_Request $request Request.
596 * @return WP_REST_Response
597 */
598 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' ) ) {
606 return new WP_REST_Response( array( 'ok' => false ), 403 );
607 }
608
609 if ( ! Settings::get( 'enabled', 1 ) || ! Settings::get( 'async_blocker', 0 ) ) {
610 return new WP_REST_Response( array( 'ok' => false ), 403 );
611 }
612
613 $params = (array) $request->get_json_params();
614 $urls = is_array( $params['urls'] ?? null ) ? array_slice( $params['urls'], 0, 25 ) : array();
615
616 $stored = (array) get_option( 'easyfonts_async_urls', array() );
617 $before = count( $stored );
618
619 foreach ( $urls as $raw ) {
620 $url = \EasyFonts\Detect\Providers::normalize_url( (string) $raw );
621
622 // Only accept genuine provider stylesheet URLs.
623 if ( ! \EasyFonts\Detect\Providers::is_css_url( $url ) ) {
624 continue;
625 }
626
627 $url = esc_url_raw( $url );
628
629 if ( '' !== $url && ! in_array( $url, $stored, true ) ) {
630 $stored[] = $url;
631 }
632 }
633
634 // Keep the queue bounded.
635 if ( count( $stored ) > 50 ) {
636 $stored = array_slice( $stored, -50 );
637 }
638
639 if ( count( $stored ) !== $before ) {
640 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();
643 }
644
645 return new WP_REST_Response( array( 'ok' => true, 'queued' => count( $stored ) ), 200 );
646 }
647
648 /**
649 * GET /export — return the current settings as a portable JSON payload.
650 *
651 * @return WP_REST_Response
652 */
653 public function export_settings(): WP_REST_Response {
654 return new WP_REST_Response(
655 array(
656 '_type' => 'easyfonts-settings',
657 '_version' => EASYFONTS_VERSION,
658 'settings' => Settings::all(),
659 ),
660 200
661 );
662 }
663
664 /**
665 * POST /import — replace settings from an exported payload. Values are run
666 * through the same validation as the normal save path (no raw write).
667 *
668 * @param WP_REST_Request $request Request.
669 * @return WP_REST_Response
670 */
671 public function import_settings( WP_REST_Request $request ): WP_REST_Response {
672 $body = (array) $request->get_json_params();
673
674 // Accept either the wrapped export ({settings:{…}}) or a bare object.
675 $incoming = isset( $body['settings'] ) && is_array( $body['settings'] ) ? $body['settings'] : $body;
676
677 if ( empty( $incoming ) ) {
678 return new WP_REST_Response( array( 'ok' => false, 'error' => 'empty' ), 400 );
679 }
680
681 $defaults = Migrator::default_settings();
682 $current = Settings::all();
683
684 // Booleans.
685 foreach ( array( 'enabled', 'auto_config', 'strip_hints', 'smart_preload', 'metric_fallbacks', 'beacon', 'inline_css', 'async_blocker', 'remove_data_on_uninstall' ) as $key ) {
686 if ( array_key_exists( $key, $incoming ) ) {
687 $current[ $key ] = $incoming[ $key ] ? 1 : 0;
688 }
689 }
690
691 // font-display.
692 if ( isset( $incoming['font_display'] ) ) {
693 $allowed = array( 'swap', 'fallback', 'optional', 'block', 'auto', 'none' );
694 $val = sanitize_text_field( (string) $incoming['font_display'] );
695 $current['font_display'] = in_array( $val, $allowed, true ) ? $val : 'swap';
696 }
697
698 // Subsets / excluded URLs (arrays of strings).
699 foreach ( array( 'subsets', 'excluded_urls' ) as $key ) {
700 if ( isset( $incoming[ $key ] ) && is_array( $incoming[ $key ] ) ) {
701 $current[ $key ] = array_values( array_filter( array_map( 'sanitize_text_field', $incoming[ $key ] ) ) );
702 }
703 }
704
705 // CDN URL.
706 if ( array_key_exists( 'cdn_url', $incoming ) ) {
707 $cdn = trim( (string) $incoming['cdn_url'] );
708
709 if ( '' !== $cdn && 0 === strpos( $cdn, '//' ) ) {
710 $cdn = ( is_ssl() ? 'https:' : 'http:' ) . $cdn;
711 }
712
713 $current['cdn_url'] = ( '' !== $cdn && preg_match( '#^https?://#i', $cdn ) )
714 ? untrailingslashit( esc_url_raw( $cdn ) )
715 : '';
716 }
717
718 // Never import a foreign detector set; keep what this site learned.
719 $current['detectors'] = (array) ( $current['detectors'] ?? $defaults['detectors'] );
720
721 Settings::save( $current );
722 Settings::bump_buster();
723
724 return new WP_REST_Response( array( 'ok' => true, 'settings' => Settings::all() ), 200 );
725 }
726 }