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

765 lines 23.6 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 * 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 }