PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 1.11.0 All 47 releases
thinkrank / includes / api / class-settings-management-endpoint.php

class-settings-management-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/api/class-settings-management-endpoint.php

2,282 lines 82.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings Management API Endpoints Class
4 *
5 * REST API endpoints for centralized settings management across all SEO managers
6 * including global settings CRUD, validation and schema management, import/export
7 * functionality, and backup/restore operations. Provides comprehensive API access
8 * to Settings Manager functionality with proper authentication and validation.
9 *
10 * @package ThinkRank
11 * @subpackage API
12 * @since 1.0.0
13 */
14
15 declare(strict_types=1);
16
17 namespace ThinkRank\API;
18
19 use ThinkRank\Core\Settings_Manager;
20 use ThinkRank\SEO\Site_Identity_Manager;
21 use ThinkRank\SEO\Performance_Monitoring_Manager;
22 use ThinkRank\SEO\AI_Content_Analyzer;
23 use ThinkRank\SEO\Content_Optimization_Manager;
24 use ThinkRank\SEO\Schema_Management_System;
25 use ThinkRank\SEO\Social_Meta_Manager;
26 use ThinkRank\SEO\Sitemap_Generator;
27 use WP_REST_Controller;
28 use WP_REST_Request;
29 use WP_REST_Response;
30 use WP_Error;
31
32 // Prevent direct access
33 if (!defined('ABSPATH')) {
34 exit;
35 }
36
37 /**
38 * Settings Management API Endpoints Class
39 *
40 * Provides REST API endpoints for centralized settings management operations
41 * including global settings CRUD, validation, import/export, backup/restore,
42 * and cross-manager settings coordination with proper authentication and validation.
43 *
44 * @since 1.0.0
45 */
46 class Settings_Management_Endpoint extends WP_REST_Controller {
47
48 /**
49 * Settings Manager instance
50 *
51 * @since 1.0.0
52 * @var Settings_Manager
53 */
54 private Settings_Manager $settings_manager;
55
56 /**
57 * Lazily constructed SEO Manager instances, keyed by category
58 *
59 * @since 1.0.0
60 * @var array
61 */
62 private array $seo_managers = [];
63
64 /**
65 * API namespace
66 *
67 * @since 1.0.0
68 * @var string
69 */
70 protected $namespace = 'thinkrank/v1';
71
72 /**
73 * API resource base
74 *
75 * @since 1.0.0
76 * @var string
77 */
78 protected $rest_base = 'settings-management';
79
80 /**
81 * Supported setting categories
82 *
83 * @since 1.0.0
84 * @var array
85 */
86 private array $setting_categories = [
87 'site_identity' => 'Site Identity & Global SEO',
88 'content_analysis' => 'AI Content Analysis',
89 'content_optimization' => 'Content Optimization',
90 'performance_monitoring' => 'Performance Monitoring',
91 'schema_management' => 'Schema Management',
92 'social_media' => 'Social Media & Open Graph',
93 'sitemap' => 'XML Sitemap Management',
94 'integrations' => 'External Integrations',
95 'analytics_integration' => 'Analytics Integration',
96 'seo_analytics' => 'SEO Analytics & Intelligence'
97 // 'global_defaults' was listed here but is registered in no settings
98 // store and read by no client — the only mention in the codebase was
99 // this label. Every save against it reached the compound write with
100 // nothing to persist to and answered 500, so accepting the name only
101 // promised a category that could never be stored. It now falls through
102 // to the 400 invalid_category branch like any other unknown name (#371).
103 ];
104
105 /**
106 * Constructor
107 *
108 * @since 1.0.0
109 */
110 public function __construct() {
111 $this->settings_manager = new Settings_Manager();
112 }
113
114 /**
115 * Category → manager class map. Instances are created lazily: this
116 * endpoint is constructed on every REST request (any namespace), and
117 * eagerly building eight manager chains added measurable overhead to
118 * unrelated requests.
119 *
120 * @var array<string,class-string>
121 */
122 private array $seo_manager_classes = [
123 'site_identity' => Site_Identity_Manager::class,
124 'performance_monitoring' => Performance_Monitoring_Manager::class,
125 // Keyed by the endpoint's own category name. It was 'ai_content_analyzer',
126 // which appears in no other registry, so the route rejected it with 400
127 // invalid_category and this manager was never reachable — while the
128 // endpoint's actual category, 'content_analysis', had no manager and
129 // therefore nowhere to persist (#371).
130 'content_analysis' => AI_Content_Analyzer::class,
131 'content_optimization' => Content_Optimization_Manager::class,
132 'schema_management' => Schema_Management_System::class,
133 'social_media' => Social_Meta_Manager::class,
134 'sitemap' => Sitemap_Generator::class,
135 'analytics_integration' => Performance_Monitoring_Manager::class,
136 ];
137
138 /**
139 * Whether a category has an associated SEO manager
140 *
141 * @param string $category Category key
142 * @return bool
143 */
144 private function has_seo_manager(string $category): bool {
145 return isset($this->seo_manager_classes[$category]);
146 }
147
148 /**
149 * Get (and lazily construct) the SEO manager for a category
150 *
151 * @param string $category Category key
152 * @return object The manager instance
153 */
154 private function get_seo_manager(string $category): object {
155 if (!isset($this->seo_managers[$category])) {
156 $class = $this->seo_manager_classes[$category];
157 $this->seo_managers[$category] = new $class();
158 }
159 return $this->seo_managers[$category];
160 }
161
162 /**
163 * Register API routes
164 *
165 * @since 1.0.0
166 */
167 public function register_routes(): void {
168 // Global settings management
169 register_rest_route(
170 $this->namespace,
171 '/' . $this->rest_base . '/global',
172 [
173 [
174 'methods' => 'GET',
175 'callback' => [$this, 'get_global_settings'],
176 'permission_callback' => [$this, 'check_read_permissions']
177 ],
178 [
179 'methods' => 'POST',
180 'callback' => [$this, 'update_global_settings'],
181 'permission_callback' => [$this, 'check_manage_permissions'],
182 'args' => $this->get_global_settings_args()
183 ]
184 ]
185 );
186
187 // Category-specific settings
188 register_rest_route(
189 $this->namespace,
190 '/' . $this->rest_base . '/category/(?P<category>[a-zA-Z0-9_-]+)',
191 [
192 [
193 'methods' => 'GET',
194 'callback' => [$this, 'get_category_settings'],
195 'permission_callback' => [$this, 'check_read_permissions'],
196 'args' => [
197 'category' => [
198 'required' => true,
199 'type' => 'string',
200 'enum' => array_keys($this->setting_categories)
201 ]
202 ]
203 ],
204 [
205 'methods' => 'POST',
206 'callback' => [$this, 'update_category_settings'],
207 'permission_callback' => [$this, 'check_manage_permissions'],
208 'args' => $this->get_category_settings_args()
209 ]
210 ]
211 );
212
213 // Settings validation and schema
214 register_rest_route(
215 $this->namespace,
216 '/' . $this->rest_base . '/validate',
217 [
218 [
219 'methods' => 'POST',
220 'callback' => [$this, 'validate_settings'],
221 'permission_callback' => [$this, 'check_read_permissions'],
222 'args' => $this->get_validation_args()
223 ]
224 ]
225 );
226
227 // Settings schema management
228 register_rest_route(
229 $this->namespace,
230 '/' . $this->rest_base . '/schema',
231 [
232 [
233 'methods' => 'GET',
234 'callback' => [$this, 'get_settings_schema'],
235 'permission_callback' => [$this, 'check_read_permissions']
236 ]
237 ]
238 );
239
240 // Settings import/export
241 register_rest_route(
242 $this->namespace,
243 '/' . $this->rest_base . '/export',
244 [
245 [
246 'methods' => 'POST',
247 'callback' => [$this, 'export_settings'],
248 'permission_callback' => [$this, 'check_manage_permissions'],
249 'args' => $this->get_export_args()
250 ]
251 ]
252 );
253
254 register_rest_route(
255 $this->namespace,
256 '/' . $this->rest_base . '/import',
257 [
258 [
259 'methods' => 'POST',
260 'callback' => [$this, 'import_settings'],
261 'permission_callback' => [$this, 'check_admin_permissions'],
262 'args' => $this->get_import_args()
263 ]
264 ]
265 );
266
267 // Settings backup/restore
268 register_rest_route(
269 $this->namespace,
270 '/' . $this->rest_base . '/backup',
271 [
272 [
273 'methods' => 'POST',
274 'callback' => [$this, 'create_settings_backup'],
275 'permission_callback' => [$this, 'check_manage_permissions'],
276 'args' => $this->get_backup_args()
277 ]
278 ]
279 );
280
281 register_rest_route(
282 $this->namespace,
283 '/' . $this->rest_base . '/restore',
284 [
285 [
286 'methods' => 'POST',
287 'callback' => [$this, 'restore_settings_backup'],
288 'permission_callback' => [$this, 'check_manage_permissions'],
289 'args' => $this->get_restore_args()
290 ]
291 ]
292 );
293
294 // Settings reset
295 register_rest_route(
296 $this->namespace,
297 '/' . $this->rest_base . '/reset',
298 [
299 [
300 'methods' => 'POST',
301 'callback' => [$this, 'reset_settings'],
302 'permission_callback' => [$this, 'check_admin_permissions'],
303 'args' => $this->get_reset_args()
304 ]
305 ]
306 );
307
308 // Database maintenance operations
309 register_rest_route(
310 $this->namespace,
311 '/' . $this->rest_base . '/maintenance/performance-indexes',
312 [
313 [
314 'methods' => 'POST',
315 'callback' => [$this, 'add_performance_indexes'],
316 'permission_callback' => [$this, 'check_admin_permissions']
317 ]
318 ]
319 );
320 }
321
322 /**
323 * Setting keys that hold secrets (encrypted at rest).
324 *
325 * Mirrors ThinkRank\Core\Settings::$encrypted_keys — keep in sync. These must
326 * never be returned decrypted from the read/export endpoints.
327 *
328 * @var string[]
329 */
330 private const SENSITIVE_SETTING_KEYS = [
331 'openai_api_key',
332 'claude_api_key',
333 'gemini_api_key',
334 'openrouter_api_key',
335 'google_analytics_api_key',
336 'google_search_console_api_key',
337 'google_pagespeed_api_key',
338 'google_access_token',
339 'google_refresh_token',
340 'pinterest_site_verification',
341 'instagram_verification',
342 'tiktok_verification',
343 ];
344
345 /**
346 * Mask a secret value for display: keeps a "has value" signal and the last
347 * four characters, never the secret itself. Empty stays empty.
348 *
349 * @param mixed $value Raw setting value.
350 * @return string Masked value.
351 */
352 private function mask_secret_value($value): string {
353 if (!is_string($value) || $value === '') {
354 return '';
355 }
356 $suffix = strlen($value) > 4 ? substr($value, -4) : '';
357 return '••••' . $suffix;
358 }
359
360 /**
361 * Redact secrets from a category => settings map before it leaves the site.
362 *
363 * Read responses mask secrets (presence + last 4). Exports drop them entirely
364 * so long-lived third-party credentials never land in an export file (and a
365 * masked value can't corrupt the real key on re-import).
366 *
367 * @param array $settings category => [key => value] map.
368 * @param bool $for_export Whether this is an export (drop) vs a read (mask).
369 * @return array Redacted map.
370 */
371 private function redact_sensitive_settings(array $settings, bool $for_export = false): array {
372 foreach ($settings as $category => $values) {
373 if (!is_array($values)) {
374 continue;
375 }
376 foreach ($values as $key => $value) {
377 if (!in_array($key, self::SENSITIVE_SETTING_KEYS, true)) {
378 continue;
379 }
380 if ($for_export) {
381 unset($values[$key]);
382 } else {
383 $values[$key] = $this->mask_secret_value($value);
384 }
385 }
386 $settings[$category] = $values;
387 }
388 return $settings;
389 }
390
391 /**
392 * Redact secrets from a single category's flat key => value map.
393 *
394 * Convenience wrapper so the single-category response shapes get the same
395 * treatment as the global map — no response path may return a cleartext
396 * secret.
397 *
398 * @param string $category Category slug.
399 * @param array $settings Flat key => value map for that category.
400 * @return array Redacted flat map.
401 */
402 private function redact_category_settings(string $category, array $settings): array {
403 $redacted = $this->redact_sensitive_settings([$category => $settings]);
404 return $redacted[$category] ?? [];
405 }
406
407 /**
408 * Drop masked secrets from an incoming write payload.
409 *
410 * Read responses return secrets masked ("••••abcd"). A client that GETs a
411 * settings map and POSTs it straight back would otherwise persist the mask
412 * over the real credential. Any sensitive key whose incoming value still
413 * carries the mask marker is removed so the stored value is left untouched;
414 * a genuinely new secret (no marker) writes through normally.
415 *
416 * @param array $settings Flat key => value map from the request.
417 * @return array Map with masked secret values removed.
418 */
419 /**
420 * Drop setting keys the category does not define.
421 *
422 * The known set is whatever describes the category: the generic store's key
423 * list, and the dedicated manager's default settings when one owns it.
424 * Fails open — if neither store can describe the category there is nothing
425 * to check against, and silently dropping everything would be worse than
426 * storing an unknown key.
427 *
428 * @since 2.0.1
429 *
430 * @param array $settings Incoming settings.
431 * @param string $category Settings category.
432 * @param string $context_type Context the write is scoped to.
433 * @return array Settings limited to recognised keys.
434 */
435 private function filter_known_setting_keys(array $settings, string $category, string $context_type): array {
436 $known = [];
437
438 // $this->setting_categories maps category => label; the key lists live
439 // in the generic store.
440 $known = array_merge($known, $this->settings_manager->get_category_keys($category));
441
442 if ($this->has_seo_manager($category)) {
443 $known = array_merge(
444 $known,
445 array_keys($this->get_seo_manager($category)->get_default_settings($context_type))
446 );
447 }
448
449 /**
450 * Filter the setting keys a category accepts.
451 *
452 * @since 2.0.1
453 *
454 * @param string[] $known Recognised setting keys.
455 * @param string $category Settings category.
456 * @param string $context_type Context the write is scoped to.
457 */
458 $known = apply_filters('thinkrank_known_setting_keys', $known, $category, $context_type);
459
460 if (empty($known)) {
461 return $settings;
462 }
463
464 return array_intersect_key($settings, array_flip($known));
465 }
466
467 private function strip_masked_secrets(array $settings): array {
468 foreach ($settings as $key => $value) {
469 if (!in_array($key, self::SENSITIVE_SETTING_KEYS, true)) {
470 continue;
471 }
472 if (is_string($value) && strpos($value, '••••') !== false) {
473 unset($settings[$key]);
474 }
475 }
476 return $settings;
477 }
478
479 /**
480 * Get global settings across all categories
481 *
482 * @since 1.0.0
483 *
484 * @param WP_REST_Request $request Request object
485 * @return WP_REST_Response Response object
486 */
487 public function get_global_settings(WP_REST_Request $request): WP_REST_Response {
488 try {
489 $include_categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
490 $include_schema = $request->get_param('include_schema') ?? false;
491
492 $global_settings = [];
493 $settings_schema = [];
494
495 foreach ($include_categories as $category) {
496 if (!isset($this->setting_categories[$category])) {
497 continue;
498 }
499
500 // Get settings for each category using Settings Manager
501 $category_settings = $this->settings_manager->get_settings($category);
502 $global_settings[$category] = $category_settings;
503
504 // Get schema if requested
505 if ($include_schema && $this->has_seo_manager($category)) {
506 $settings_schema[$category] = $this->get_seo_manager($category)->get_settings_schema($category);
507 }
508 }
509
510 // Get global metadata
511 $metadata = [
512 'total_categories' => count($this->setting_categories),
513 'loaded_categories' => count($global_settings),
514 'last_updated' => $this->get_last_settings_update(),
515 'settings_version' => $this->get_settings_version()
516 ];
517
518 return new WP_REST_Response([
519 'success' => true,
520 'data' => [
521 'settings' => $this->redact_sensitive_settings($global_settings),
522 'schema' => $settings_schema,
523 'metadata' => $metadata,
524 'categories' => $this->setting_categories
525 ],
526 'message' => 'Global settings retrieved successfully'
527 ], 200);
528
529 } catch (\Exception $e) {
530 return new WP_REST_Response([
531 'success' => false,
532 'error' => 'Failed to retrieve global settings: ' . $e->getMessage()
533 ], 500);
534 }
535 }
536
537 /**
538 * Update global settings across multiple categories
539 *
540 * @since 1.0.0
541 *
542 * @param WP_REST_Request $request Request object
543 * @return WP_REST_Response|WP_Error Response object or error
544 */
545 public function update_global_settings(WP_REST_Request $request) {
546 try {
547 $settings = $request->get_param('settings');
548 $validate_before_update = $request->get_param('validate') ?? true;
549
550 // Validate settings structure
551 if (empty($settings) || !is_array($settings)) {
552 return new WP_Error(
553 'invalid_settings',
554 'Settings must be provided as an array',
555 ['status' => 400]
556 );
557 }
558
559 // Each per-category value must be an array before it reaches the
560 // strict array-typed manager methods; reject non-array values with a
561 // 400 instead of letting them surface as an uncaught TypeError.
562 foreach ($settings as $category => $category_settings) {
563 if (!is_array($category_settings)) {
564 return new WP_Error(
565 'invalid_settings',
566 "Settings for category '{$category}' must be provided as an object",
567 ['status' => 400]
568 );
569 }
570
571 // Reads mask secrets; never persist a mask back over the real one.
572 $settings[$category] = $this->strip_masked_secrets($category_settings);
573 }
574
575 $validation_results = [];
576 $update_results = [];
577
578 // Validate all settings before updating if requested
579 if ($validate_before_update) {
580 foreach ($settings as $category => $category_settings) {
581 if (!isset($this->setting_categories[$category])) {
582 continue;
583 }
584
585 if ($this->has_seo_manager($category)) {
586 $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
587 $validation_results[$category] = $validation;
588
589 if (!$validation['valid']) {
590 return new WP_Error(
591 'validation_failed',
592 "Settings validation failed for category: {$category}",
593 [
594 'status' => 400,
595 'validation_results' => $validation_results
596 ]
597 );
598 }
599 }
600 }
601 }
602
603 // Update settings for each category
604 foreach ($settings as $category => $category_settings) {
605 if (!isset($this->setting_categories[$category])) {
606 continue;
607 }
608
609 try {
610 // Update using Settings Manager
611 $update_success = $this->settings_manager->update_settings($category_settings, $category);
612
613 // Also update through specific SEO manager if available
614 if ($this->has_seo_manager($category)) {
615 $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
616 $update_success = $update_success && $manager_update;
617 }
618
619 $update_results[$category] = [
620 'success' => $update_success,
621 'settings_count' => count($category_settings)
622 ];
623
624 } catch (\Exception $e) {
625 $update_results[$category] = [
626 'success' => false,
627 'error' => $e->getMessage()
628 ];
629 }
630 }
631
632 // Update settings version and timestamp
633 $this->update_settings_metadata();
634
635 // Get updated settings
636 $updated_settings = [];
637 foreach (array_keys($settings) as $category) {
638 if (isset($this->setting_categories[$category])) {
639 $updated_settings[$category] = $this->settings_manager->get_settings($category);
640 }
641 }
642
643 return new WP_REST_Response([
644 'success' => true,
645 'data' => [
646 'updated_settings' => $this->redact_sensitive_settings($updated_settings),
647 'validation_results' => $validation_results,
648 'update_results' => $update_results,
649 'settings_version' => $this->get_settings_version()
650 ],
651 'message' => 'Global settings updated successfully'
652 ], 200);
653
654 } catch (\Exception $e) {
655 return new WP_Error(
656 'update_failed',
657 'Global settings update failed: ' . $e->getMessage(),
658 ['status' => 500]
659 );
660 }
661 }
662
663 /**
664 * Get settings for specific category
665 *
666 * @since 1.0.0
667 *
668 * @param WP_REST_Request $request Request object
669 * @return WP_REST_Response|WP_Error Response object or error
670 */
671 public function get_category_settings(WP_REST_Request $request) {
672 try {
673 $category = $request->get_param('category');
674 $include_schema = $request->get_param('include_schema') ?? false;
675
676 // Validate category
677 if (!isset($this->setting_categories[$category])) {
678 return new WP_Error(
679 'invalid_category',
680 'Invalid settings category provided',
681 ['status' => 400]
682 );
683 }
684
685 // Get category settings
686 $category_settings = $this->settings_manager->get_settings($category);
687
688 // Get schema if requested
689 $schema = [];
690 if ($include_schema && $this->has_seo_manager($category)) {
691 $schema = $this->get_seo_manager($category)->get_settings_schema($category);
692 }
693
694 // Get category metadata
695 $metadata = [
696 'category' => $category,
697 'category_name' => $this->setting_categories[$category],
698 'settings_count' => count($category_settings),
699 'last_updated' => $this->get_category_last_update($category),
700 'has_manager' => $this->has_seo_manager($category)
701 ];
702
703 return new WP_REST_Response([
704 'success' => true,
705 'data' => [
706 'settings' => $this->redact_category_settings($category, $category_settings),
707 'schema' => $schema,
708 'metadata' => $metadata
709 ],
710 'message' => "Settings for category '{$category}' retrieved successfully"
711 ], 200);
712
713 } catch (\Exception $e) {
714 return new WP_Error(
715 'retrieval_failed',
716 'Category settings retrieval failed: ' . $e->getMessage(),
717 ['status' => 500]
718 );
719 }
720 }
721
722 /**
723 * Update settings for specific category
724 *
725 * @since 1.0.0
726 *
727 * @param WP_REST_Request $request Request object
728 * @return WP_REST_Response|WP_Error Response object or error
729 */
730 public function update_category_settings(WP_REST_Request $request) {
731 try {
732 $category = $request->get_param('category');
733 $request_data = $request->get_param('settings');
734 $validate_before_update = $request->get_param('validate') ?? true;
735
736 // Extract only the actual settings data, not metadata
737 if (isset($request_data['settings'])) {
738 // If settings are nested under 'settings' key, use that
739 $settings = $request_data['settings'];
740 } else {
741 // Otherwise use the data directly
742 $settings = $request_data;
743 }
744
745 // Validate category
746 if (!isset($this->setting_categories[$category])) {
747 return new WP_Error(
748 'invalid_category',
749 'Invalid settings category provided',
750 ['status' => 400]
751 );
752 }
753
754 // Validate settings
755 if (empty($settings) || !is_array($settings)) {
756 return new WP_Error(
757 'invalid_settings',
758 'Settings must be provided as an array',
759 ['status' => 400]
760 );
761 }
762
763 // SECURITY: this route also accepts an object context and forwards it
764 // to the category's SEO manager, which upserts rows keyed by that ID.
765 // The `thinkrank_settings` capability authorises entry to the Settings
766 // section — it is not authorisation to edit every post on the site — so
767 // resolve and authorise the object before ANY write happens below (#367).
768 $context_type = $request->get_param('context_type') ?? 'site';
769 $context_id = $request->get_param('context_id');
770 $context_id = null === $context_id ? null : (int) $context_id;
771
772 $context_error = $this->authorize_settings_context($context_type, $context_id);
773 if (is_wp_error($context_error)) {
774 return $context_error;
775 }
776
777 // Reads mask secrets; never persist a mask back over the real one.
778 $settings = $this->strip_masked_secrets($settings);
779
780 // Drop keys the category does not define. This route persisted any
781 // key it was handed — a probe key written through it is still
782 // readable in the settings table afterwards — which bloats the
783 // store and lets a client invent settings the plugin will never
784 // read (#395). Mirrors the same guard on the schema and
785 // social-media routes.
786 $settings = $this->filter_known_setting_keys($settings, $category, $context_type);
787
788 if (empty($settings)) {
789 return new WP_Error(
790 'invalid_settings',
791 "No recognized settings were provided for category: {$category}",
792 ['status' => 400]
793 );
794 }
795
796 $validation_result = ['valid' => true];
797
798 // Validate settings if requested
799 if ($validate_before_update && $this->has_seo_manager($category)) {
800 $validation_result = $this->get_seo_manager($category)->validate_settings($settings);
801
802 if (!$validation_result['valid']) {
803 return new WP_Error(
804 'validation_failed',
805 "Settings validation failed for category: {$category}",
806 [
807 'status' => 400,
808 'validation_errors' => $validation_result['errors'],
809 'validation_warnings' => $validation_result['warnings']
810 ]
811 );
812 }
813 }
814
815 // Update settings. The context must be forwarded: update_settings()
816 // defaults to the 'site' context, so a post-scoped request was also
817 // silently rewriting the site-wide defaults (#367).
818 $generic_update = $this->settings_manager->update_settings($settings, $category, $context_type, $context_id);
819 $manager_update = null;
820
821 // Also update through specific SEO manager if available. The context was
822 // resolved and authorised above.
823 if ($this->has_seo_manager($category)) {
824 $manager_update = $this->get_seo_manager($category)->save_settings($context_type, $context_id, $settings);
825 }
826
827 // null from a store means "this category is not mine", not "the write
828 // failed" — the two registries use different category vocabularies, so
829 // most categories are owned by exactly one store (#371). Judge only the
830 // stores that actually attempted a write: the save succeeded if at least
831 // one store owned the category and none of the owners failed. ANDing the
832 // raw values reported 500 for every category the generic store does not
833 // know, while the dedicated manager's row had already committed.
834 $attempted = array_filter(
835 [$generic_update, $manager_update],
836 static fn($result) => null !== $result
837 );
838
839 $update_success = [] !== $attempted && !in_array(false, $attempted, true);
840
841 if (!$update_success) {
842 // Name the settings that did not persist. The write is not
843 // transactional, so "failed" can mean some keys saved and others
844 // did not — without the list the UI can only show a generic
845 // error and the user has no idea what to re-enter (#300).
846 $failed_keys = $this->settings_manager->get_last_failed_keys();
847
848 // Report which store failed. Collapsing both writes into one boolean
849 // meant a committed manager row could be reported as a total failure,
850 // hiding a persisted change behind a 500 (#367). Only a literal false
851 // is a failure — null means the store does not own this category and
852 // never attempted a write, so it must not be named here (#371).
853 $stores_failed = [];
854 if (false === $generic_update) {
855 $stores_failed[] = 'settings';
856 }
857 if (false === $manager_update) {
858 $stores_failed[] = 'category_manager';
859 }
860
861 // No store owns the category. That is a routing defect rather than a
862 // failed write, and it is worth distinguishing: the settings were
863 // never persisted anywhere, so reporting it as a plain write failure
864 // would send the user back to re-enter values that have nowhere to go.
865 if ([] === $attempted) {
866 return new WP_Error(
867 'category_not_persistable',
868 sprintf(
869 'No settings store is registered for category %s, so nothing was saved.',
870 $category
871 ),
872 [
873 'status' => 500,
874 'failed_keys' => $failed_keys,
875 'stores_failed' => $stores_failed,
876 'partial_write' => false,
877 ]
878 );
879 }
880
881 return new WP_Error(
882 'update_failed',
883 empty($failed_keys)
884 ? "Failed to update settings for category: {$category}"
885 : sprintf(
886 'Failed to save %s in category %s. Other settings in this request were saved.',
887 implode(', ', $failed_keys),
888 $category
889 ),
890 [
891 'status' => 500,
892 'failed_keys' => $failed_keys,
893 'stores_failed' => $stores_failed,
894 // True when more than one store attempted the write and they
895 // disagreed, so the client knows the request was not a clean
896 // no-op. Stores that did not own the category are excluded.
897 'partial_write' => in_array(true, $attempted, true)
898 && in_array(false, $attempted, true),
899 ]
900 );
901 }
902
903 // Clear analytics cache when GSC/GA settings change so fresh data is fetched
904 if ($category === 'seo_analytics') {
905 foreach (['7d', '30d', '90d'] as $range) {
906 delete_transient("analytics_dashboard_v5_{$range}");
907 delete_transient("seo_opportunities_{$range}");
908 delete_transient("seo_insights_{$range}");
909 }
910 delete_transient('indexing_status');
911 }
912
913 // Update category metadata
914 $this->update_category_metadata($category);
915
916 // Get updated settings. Read them back from whichever store actually
917 // owns the category: the generic store returns [] for the categories it
918 // does not know, which would report a successful save as zero settings
919 // and hand the UI an empty form to render (#371).
920 $updated_settings = null === $generic_update && $this->has_seo_manager($category)
921 ? $this->get_seo_manager($category)->get_settings($context_type, $context_id)
922 : $this->settings_manager->get_settings($category, $context_type, $context_id);
923
924 return new WP_REST_Response([
925 'success' => true,
926 'data' => [
927 'category' => $category,
928 'updated_settings' => $this->redact_category_settings($category, $updated_settings),
929 'validation_result' => $validation_result,
930 'settings_count' => count($updated_settings)
931 ],
932 'message' => "Settings for category '{$category}' updated successfully"
933 ], 200);
934
935 } catch (\Exception $e) {
936 return new WP_Error(
937 'update_failed',
938 'Category settings update failed: ' . $e->getMessage(),
939 ['status' => 500]
940 );
941 }
942 }
943
944 /**
945 * Validate settings across categories
946 *
947 * @since 1.0.0
948 *
949 * @param WP_REST_Request $request Request object
950 * @return WP_REST_Response Response object
951 */
952 public function validate_settings(WP_REST_Request $request): WP_REST_Response {
953 try {
954 $settings = $request->get_param('settings');
955 if (!is_array($settings)) {
956 $settings = [];
957 }
958 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
959
960 $validation_results = [];
961 $overall_valid = true;
962
963 foreach ($categories as $category) {
964 if (!isset($this->setting_categories[$category])) {
965 continue;
966 }
967
968 $category_settings = $settings[$category] ?? [];
969 if (!is_array($category_settings)) {
970 $validation_results[$category] = [
971 'valid' => false,
972 'errors' => ['Settings for this category must be an object'],
973 'warnings' => [],
974 'suggestions' => [],
975 ];
976 $overall_valid = false;
977 continue;
978 }
979
980 if ($this->has_seo_manager($category)) {
981 $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
982 $validation_results[$category] = $validation;
983
984 if (!$validation['valid']) {
985 $overall_valid = false;
986 }
987 } else {
988 // Basic validation for categories without specific managers
989 $validation_results[$category] = [
990 'valid' => true,
991 'errors' => [],
992 'warnings' => [],
993 'suggestions' => []
994 ];
995 }
996 }
997
998 return new WP_REST_Response([
999 'success' => true,
1000 'data' => [
1001 'validation_results' => $validation_results,
1002 'overall_valid' => $overall_valid,
1003 'validated_categories' => count($validation_results),
1004 'validation_timestamp' => current_time('mysql')
1005 ],
1006 'message' => 'Settings validation completed'
1007 ], 200);
1008
1009 } catch (\Exception $e) {
1010 return new WP_REST_Response([
1011 'success' => false,
1012 'error' => 'Settings validation failed: ' . $e->getMessage()
1013 ], 500);
1014 }
1015 }
1016
1017 /**
1018 * Get settings schema for all categories
1019 *
1020 * @since 1.0.0
1021 *
1022 * @param WP_REST_Request $request Request object
1023 * @return WP_REST_Response Response object
1024 */
1025 public function get_settings_schema(WP_REST_Request $request): WP_REST_Response {
1026 try {
1027 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1028
1029 $schema_data = [];
1030
1031 foreach ($categories as $category) {
1032 if (!isset($this->setting_categories[$category])) {
1033 continue;
1034 }
1035
1036 if ($this->has_seo_manager($category)) {
1037 $schema_data[$category] = [
1038 'schema' => $this->get_seo_manager($category)->get_settings_schema($category),
1039 'defaults' => $this->get_seo_manager($category)->get_default_settings($category),
1040 'category_name' => $this->setting_categories[$category]
1041 ];
1042 } else {
1043 $schema_data[$category] = [
1044 'schema' => [],
1045 'defaults' => [],
1046 'category_name' => $this->setting_categories[$category]
1047 ];
1048 }
1049 }
1050
1051 return new WP_REST_Response([
1052 'success' => true,
1053 'data' => [
1054 'schema' => $schema_data,
1055 'categories' => $this->setting_categories,
1056 'schema_version' => $this->get_schema_version(),
1057 'generated_at' => current_time('mysql')
1058 ],
1059 'message' => 'Settings schema retrieved successfully'
1060 ], 200);
1061
1062 } catch (\Exception $e) {
1063 return new WP_REST_Response([
1064 'success' => false,
1065 'error' => 'Failed to retrieve settings schema: ' . $e->getMessage()
1066 ], 500);
1067 }
1068 }
1069
1070 /**
1071 * Export settings
1072 *
1073 * @since 1.0.0
1074 *
1075 * @param WP_REST_Request $request Request object
1076 * @return WP_REST_Response|WP_Error Response object or error
1077 */
1078 public function export_settings(WP_REST_Request $request) {
1079 try {
1080 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1081 $format = $request->get_param('format') ?? 'json';
1082 $include_metadata = $request->get_param('include_metadata') ?? true;
1083
1084 // Validate format
1085 if (!in_array($format, ['json', 'yaml', 'xml'], true)) {
1086 return new WP_Error(
1087 'invalid_format',
1088 'Invalid export format. Supported formats: json, yaml, xml',
1089 ['status' => 400]
1090 );
1091 }
1092
1093 $export_data = [];
1094
1095 // Export settings for each category
1096 foreach ($categories as $category) {
1097 if (!isset($this->setting_categories[$category])) {
1098 continue;
1099 }
1100
1101 $export_data[$category] = $this->settings_manager->get_settings($category);
1102 }
1103
1104 // Never let secrets (API keys, OAuth tokens) leave the site in an
1105 // export file — strip them entirely.
1106 $export_data = $this->redact_sensitive_settings($export_data, true);
1107
1108 // Add metadata if requested
1109 $metadata = [];
1110 if ($include_metadata) {
1111 $metadata = [
1112 'export_timestamp' => current_time('mysql'),
1113 'export_version' => $this->get_settings_version(),
1114 'wordpress_version' => get_bloginfo('version'),
1115 'thinkrank_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '',
1116 'site_url' => home_url(),
1117 'exported_categories' => $categories
1118 ];
1119 }
1120
1121 // Format export data
1122 $formatted_export = $this->format_export_data($export_data, $metadata, $format);
1123
1124 return new WP_REST_Response([
1125 'success' => true,
1126 'data' => [
1127 'export_data' => $formatted_export,
1128 'format' => $format,
1129 'metadata' => $metadata,
1130 'exported_categories' => count($export_data)
1131 ],
1132 'message' => 'Settings exported successfully'
1133 ], 200);
1134
1135 } catch (\Exception $e) {
1136 return new WP_Error(
1137 'export_failed',
1138 'Settings export failed: ' . $e->getMessage(),
1139 ['status' => 500]
1140 );
1141 }
1142 }
1143
1144 /**
1145 * Import settings
1146 *
1147 * @since 1.0.0
1148 *
1149 * @param WP_REST_Request $request Request object
1150 * @return WP_REST_Response|WP_Error Response object or error
1151 */
1152 public function import_settings(WP_REST_Request $request) {
1153 try {
1154 $import_data = $request->get_param('import_data');
1155 $format = $request->get_param('format') ?? 'json';
1156 $validate_before_import = $request->get_param('validate') ?? true;
1157 $overwrite_existing = $request->get_param('overwrite_existing') ?? false;
1158
1159 // Validate import data
1160 if (empty($import_data)) {
1161 return new WP_Error(
1162 'missing_import_data',
1163 'Import data is required',
1164 ['status' => 400]
1165 );
1166 }
1167
1168 // Parse import data based on format
1169 $parsed_data = $this->parse_import_data($import_data, $format);
1170
1171 if (!$parsed_data) {
1172 return new WP_Error(
1173 'invalid_import_data',
1174 'Failed to parse import data',
1175 ['status' => 400]
1176 );
1177 }
1178
1179 if (!is_array($parsed_data)) {
1180 return new WP_Error(
1181 'invalid_import_data',
1182 'Import data must be an object of settings categories',
1183 ['status' => 400]
1184 );
1185 }
1186
1187 // Reject non-array per-category values before they reach the strict
1188 // array-typed manager methods (avoids an uncaught TypeError).
1189 foreach ($parsed_data as $category => $category_settings) {
1190 if (!is_array($category_settings)) {
1191 return new WP_Error(
1192 'invalid_import_data',
1193 "Settings for category '{$category}' must be an object",
1194 ['status' => 400]
1195 );
1196 }
1197 }
1198
1199 $import_results = [];
1200 $validation_results = [];
1201
1202 // Validate imported settings if requested
1203 if ($validate_before_import) {
1204 foreach ($parsed_data as $category => $category_settings) {
1205 if (!isset($this->setting_categories[$category])) {
1206 continue;
1207 }
1208
1209 if ($this->has_seo_manager($category)) {
1210 $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
1211 $validation_results[$category] = $validation;
1212
1213 if (!$validation['valid']) {
1214 return new WP_Error(
1215 'import_validation_failed',
1216 "Import validation failed for category: {$category}",
1217 [
1218 'status' => 400,
1219 'validation_results' => $validation_results
1220 ]
1221 );
1222 }
1223 }
1224 }
1225 }
1226
1227 // Import settings for each category
1228 foreach ($parsed_data as $category => $category_settings) {
1229 if (!isset($this->setting_categories[$category])) {
1230 $import_results[$category] = [
1231 'success' => false,
1232 'error' => 'Invalid category'
1233 ];
1234 continue;
1235 }
1236
1237 try {
1238 // Check if settings exist and handle overwrite
1239 $existing_settings = $this->settings_manager->get_settings($category);
1240
1241 if (!empty($existing_settings) && !$overwrite_existing) {
1242 $import_results[$category] = [
1243 'success' => false,
1244 'error' => 'Settings exist and overwrite is disabled'
1245 ];
1246 continue;
1247 }
1248
1249 // Import settings
1250 $import_success = $this->settings_manager->update_settings($category_settings, $category);
1251
1252 // Also update through specific SEO manager if available
1253 if ($this->has_seo_manager($category)) {
1254 $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
1255 $import_success = $import_success && $manager_update;
1256 }
1257
1258 $import_results[$category] = [
1259 'success' => $import_success,
1260 'settings_count' => count($category_settings)
1261 ];
1262
1263 } catch (\Exception $e) {
1264 $import_results[$category] = [
1265 'success' => false,
1266 'error' => $e->getMessage()
1267 ];
1268 }
1269 }
1270
1271 // Update settings metadata
1272 $this->update_settings_metadata();
1273
1274 return new WP_REST_Response([
1275 'success' => true,
1276 'data' => [
1277 'import_results' => $import_results,
1278 'validation_results' => $validation_results,
1279 'imported_categories' => count($import_results),
1280 'successful_imports' => count(array_filter($import_results, function($result) {
1281 return $result['success'];
1282 }))
1283 ],
1284 'message' => 'Settings import completed'
1285 ], 200);
1286
1287 } catch (\Exception $e) {
1288 return new WP_Error(
1289 'import_failed',
1290 'Settings import failed: ' . $e->getMessage(),
1291 ['status' => 500]
1292 );
1293 }
1294 }
1295
1296 /**
1297 * Create settings backup
1298 *
1299 * @since 1.0.0
1300 *
1301 * @param WP_REST_Request $request Request object
1302 * @return WP_REST_Response|WP_Error Response object or error
1303 */
1304 public function create_settings_backup(WP_REST_Request $request) {
1305 try {
1306 $backup_name = $request->get_param('backup_name') ?? 'backup_' . gmdate('Y-m-d_H-i-s');
1307 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1308 $description = $request->get_param('description') ?? '';
1309
1310 // Create backup data
1311 $backup_data = [];
1312 foreach ($categories as $category) {
1313 if (isset($this->setting_categories[$category])) {
1314 $backup_data[$category] = $this->settings_manager->get_settings($category);
1315 }
1316 }
1317
1318 // Create backup metadata
1319 $backup_metadata = [
1320 'backup_name' => $backup_name,
1321 'description' => $description,
1322 'created_at' => current_time('mysql'),
1323 'created_by' => get_current_user_id(),
1324 'categories' => $categories,
1325 'settings_version' => $this->get_settings_version(),
1326 'wordpress_version' => get_bloginfo('version')
1327 ];
1328
1329 // Save backup
1330 $backup_id = $this->save_settings_backup($backup_data, $backup_metadata);
1331
1332 if (!$backup_id) {
1333 return new WP_Error(
1334 'backup_failed',
1335 'Failed to create settings backup',
1336 ['status' => 500]
1337 );
1338 }
1339
1340 return new WP_REST_Response([
1341 'success' => true,
1342 'data' => [
1343 'backup_id' => $backup_id,
1344 'backup_name' => $backup_name,
1345 'backup_metadata' => $backup_metadata,
1346 'backed_up_categories' => count($backup_data)
1347 ],
1348 'message' => 'Settings backup created successfully'
1349 ], 200);
1350
1351 } catch (\Exception $e) {
1352 return new WP_Error(
1353 'backup_failed',
1354 'Settings backup failed: ' . $e->getMessage(),
1355 ['status' => 500]
1356 );
1357 }
1358 }
1359
1360 /**
1361 * Restore settings from backup
1362 *
1363 * @since 1.0.0
1364 *
1365 * @param WP_REST_Request $request Request object
1366 * @return WP_REST_Response|WP_Error Response object or error
1367 */
1368 public function restore_settings_backup(WP_REST_Request $request) {
1369 try {
1370 $backup_id = $request->get_param('backup_id');
1371 $categories = $request->get_param('categories') ?? null;
1372 $create_restore_point = $request->get_param('create_restore_point') ?? true;
1373
1374 // Validate backup ID
1375 if (empty($backup_id)) {
1376 return new WP_Error(
1377 'missing_backup_id',
1378 'Backup ID is required',
1379 ['status' => 400]
1380 );
1381 }
1382
1383 // Load backup data
1384 $backup_data = $this->load_settings_backup($backup_id);
1385
1386 if (!$backup_data) {
1387 return new WP_Error(
1388 'backup_not_found',
1389 'Backup not found or could not be loaded',
1390 ['status' => 404]
1391 );
1392 }
1393
1394 // Create restore point if requested. Abort if it couldn't be saved,
1395 // so the current configuration isn't overwritten with no rollback.
1396 $restore_point_id = null;
1397 if ($create_restore_point) {
1398 $restore_point_id = $this->create_restore_point();
1399 if ($restore_point_id === '') {
1400 return new WP_Error(
1401 'restore_point_failed',
1402 'Could not create a restore point; aborting restore to avoid unrecoverable settings loss.',
1403 ['status' => 500]
1404 );
1405 }
1406 }
1407
1408 $restore_results = [];
1409
1410 // Determine categories to restore
1411 $categories_to_restore = $categories ?? array_keys($backup_data['settings']);
1412
1413 // Restore settings for each category
1414 foreach ($categories_to_restore as $category) {
1415 if (!isset($backup_data['settings'][$category])) {
1416 $restore_results[$category] = [
1417 'success' => false,
1418 'error' => 'Category not found in backup'
1419 ];
1420 continue;
1421 }
1422
1423 try {
1424 $category_settings = $backup_data['settings'][$category];
1425
1426 // Restore settings
1427 $restore_success = $this->settings_manager->update_settings($category_settings, $category);
1428
1429 // Also update through specific SEO manager if available
1430 if ($this->has_seo_manager($category)) {
1431 $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
1432 $restore_success = $restore_success && $manager_update;
1433 }
1434
1435 $restore_results[$category] = [
1436 'success' => $restore_success,
1437 'settings_count' => count($category_settings)
1438 ];
1439
1440 } catch (\Exception $e) {
1441 $restore_results[$category] = [
1442 'success' => false,
1443 'error' => $e->getMessage()
1444 ];
1445 }
1446 }
1447
1448 // Update settings metadata
1449 $this->update_settings_metadata();
1450
1451 return new WP_REST_Response([
1452 'success' => true,
1453 'data' => [
1454 'backup_id' => $backup_id,
1455 'restore_point_id' => $restore_point_id,
1456 'restore_results' => $restore_results,
1457 'restored_categories' => count($restore_results),
1458 'backup_metadata' => $backup_data['metadata']
1459 ],
1460 'message' => 'Settings restored from backup successfully'
1461 ], 200);
1462
1463 } catch (\Exception $e) {
1464 return new WP_Error(
1465 'restore_failed',
1466 'Settings restore failed: ' . $e->getMessage(),
1467 ['status' => 500]
1468 );
1469 }
1470 }
1471
1472 /**
1473 * Reset settings to defaults
1474 *
1475 * @since 1.0.0
1476 *
1477 * @param WP_REST_Request $request Request object
1478 * @return WP_REST_Response|WP_Error Response object or error
1479 */
1480 public function reset_settings(WP_REST_Request $request) {
1481 try {
1482 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1483 $create_backup = $request->get_param('create_backup') ?? true;
1484
1485 // Create backup before reset if requested. If the backup was asked
1486 // for but couldn't be persisted, abort rather than silently wiping
1487 // settings with no rollback — the whole point of the flag is safety.
1488 $backup_id = null;
1489 if ($create_backup) {
1490 $backup_id = $this->create_pre_reset_backup($categories);
1491 if ($backup_id === '') {
1492 return new WP_Error(
1493 'backup_failed',
1494 'Could not create a pre-reset backup; aborting reset to avoid unrecoverable settings loss.',
1495 ['status' => 500]
1496 );
1497 }
1498 }
1499
1500 $reset_results = [];
1501
1502 foreach ($categories as $category) {
1503 if (!isset($this->setting_categories[$category])) {
1504 continue;
1505 }
1506
1507 try {
1508 // Get default settings
1509 $default_settings = [];
1510 if ($this->has_seo_manager($category)) {
1511 $default_settings = $this->get_seo_manager($category)->get_default_settings($category);
1512 }
1513
1514 // Reset to defaults
1515 $reset_success = $this->settings_manager->update_settings($default_settings, $category);
1516
1517 // Also reset through specific SEO manager if available
1518 if ($this->has_seo_manager($category)) {
1519 $manager_reset = $this->get_seo_manager($category)->save_settings('site', null, $default_settings);
1520 $reset_success = $reset_success && $manager_reset;
1521 }
1522
1523 $reset_results[$category] = [
1524 'success' => $reset_success,
1525 'default_settings_count' => count($default_settings)
1526 ];
1527
1528 } catch (\Exception $e) {
1529 $reset_results[$category] = [
1530 'success' => false,
1531 'error' => $e->getMessage()
1532 ];
1533 }
1534 }
1535
1536 // Update settings metadata
1537 $this->update_settings_metadata();
1538
1539 return new WP_REST_Response([
1540 'success' => true,
1541 'data' => [
1542 'reset_results' => $reset_results,
1543 'backup_id' => $backup_id,
1544 'reset_categories' => count($reset_results),
1545 'reset_timestamp' => current_time('mysql')
1546 ],
1547 'message' => 'Settings reset to defaults completed'
1548 ], 200);
1549
1550 } catch (\Exception $e) {
1551 return new WP_Error(
1552 'reset_failed',
1553 'Settings reset failed: ' . $e->getMessage(),
1554 ['status' => 500]
1555 );
1556 }
1557 }
1558
1559 /**
1560 * Add performance indexes to database tables
1561 *
1562 * @since 1.0.0
1563 *
1564 * @param WP_REST_Request $request Request object
1565 * @return WP_REST_Response|WP_Error Response object
1566 */
1567 public function add_performance_indexes(WP_REST_Request $request) {
1568 try {
1569 // Import the Database_Schema class
1570 if (!class_exists('ThinkRank\\Database\\Database_Schema')) {
1571 require_once THINKRANK_PLUGIN_DIR . 'includes/database/class-database-schema.php';
1572 }
1573
1574 $schema = new \ThinkRank\Database\Database_Schema();
1575 $success = $schema->add_performance_indexes();
1576
1577 if ($success) {
1578 return new WP_REST_Response([
1579 'success' => true,
1580 'message' => 'Performance indexes added successfully',
1581 'data' => [
1582 'indexes_added' => true,
1583 'timestamp' => current_time('mysql')
1584 ]
1585 ], 200);
1586 } else {
1587 return new WP_REST_Response([
1588 'success' => false,
1589 'message' => 'Some performance indexes could not be added. Check error logs for details.',
1590 'data' => [
1591 'indexes_added' => false,
1592 'timestamp' => current_time('mysql')
1593 ]
1594 ], 200);
1595 }
1596
1597 } catch (\Exception $e) {
1598 return new WP_Error(
1599 'performance_indexes_failed',
1600 'Failed to add performance indexes: ' . $e->getMessage(),
1601 ['status' => 500]
1602 );
1603 }
1604 }
1605
1606 /**
1607 * Permission callbacks
1608 */
1609
1610 /**
1611 * Check permissions for reading settings data
1612 *
1613 * @since 1.0.0
1614 *
1615 * @return bool Permission status
1616 */
1617 public function check_read_permissions(WP_REST_Request $request): bool {
1618 // Plugin SEO/AI config is not subscriber-visible — require the same
1619 // management capability as the write routes, resolved per category so a
1620 // role granted one section can reach that section and no other (#573).
1621 return \ThinkRank\Core\Capability_Manager::current_user_can(
1622 $this->capability_for_request($request)
1623 );
1624 }
1625
1626 /**
1627 * The capability a settings-management request requires.
1628 *
1629 * Category routes belong to the section owning the category; every other
1630 * route on this controller is plugin-wide configuration and stays on
1631 * `thinkrank_settings`. The gate in Role_Manager::gate_rest() reaches the
1632 * same answer through Capability_Manager::capability_for_route() — both are
1633 * kept so neither layer alone is load-bearing.
1634 *
1635 * @since 2.1.3
1636 *
1637 * @param WP_REST_Request $request Request.
1638 * @return string
1639 */
1640 private function capability_for_request(WP_REST_Request $request): string {
1641 // URL params only. get_param() searches the JSON body, the POST body
1642 // and the query string ahead of the route path, so on the routes that
1643 // declare no {category} — /global, /validate, /schema, /export,
1644 // /backup, /restore — it read pure caller input and let a request
1645 // nominate the capability it would be checked against (#582). Reading
1646 // the path is also what Role_Manager::gate_rest() does, so the two
1647 // layers now agree and the claim above is true again.
1648 $category = $request->get_url_params()['category'] ?? null;
1649
1650 if (!is_string($category) || '' === $category) {
1651 return 'thinkrank_settings';
1652 }
1653
1654 return \ThinkRank\Core\Capability_Manager::capability_for_settings_category($category);
1655 }
1656
1657 /**
1658 * Check permissions for managing settings
1659 *
1660 * @since 1.0.0
1661 *
1662 * @return bool Permission status
1663 */
1664 public function check_manage_permissions(WP_REST_Request $request): bool {
1665 return \ThinkRank\Core\Capability_Manager::current_user_can(
1666 $this->capability_for_request($request)
1667 );
1668 }
1669
1670 /**
1671 * Check permissions for administrator-only settings operations.
1672 *
1673 * The Role Manager can delegate `thinkrank_settings` to non-admin roles so
1674 * they can manage the plugin's SEO configuration. Schema-level (DDL) and
1675 * destructive whole-configuration operations — performance indexes, reset,
1676 * import — are a different altitude and stay with site administrators.
1677 *
1678 * @since 1.29.0
1679 *
1680 * @return bool Permission status
1681 */
1682 public function check_admin_permissions(): bool {
1683 return current_user_can('manage_options');
1684 }
1685
1686 /**
1687 * Helper methods
1688 */
1689
1690 /**
1691 * Get last settings update timestamp
1692 *
1693 * @since 1.0.0
1694 *
1695 * @return string|null Last update timestamp
1696 */
1697 private function get_last_settings_update(): ?string {
1698 $result = get_option('thinkrank_settings_last_updated');
1699 return $result !== false ? $result : null;
1700 }
1701
1702 /**
1703 * Get settings version
1704 *
1705 * @since 1.0.0
1706 *
1707 * @return string Settings version
1708 */
1709 private function get_settings_version(): string {
1710 return get_option('thinkrank_settings_version', '1.0.0');
1711 }
1712
1713 /**
1714 * Get schema version
1715 *
1716 * @since 1.0.0
1717 *
1718 * @return string Schema version
1719 */
1720 private function get_schema_version(): string {
1721 return get_option('thinkrank_schema_version', '1.0.0');
1722 }
1723
1724 /**
1725 * Get category last update timestamp
1726 *
1727 * @since 1.0.0
1728 *
1729 * @param string $category Category name
1730 * @return string|null Last update timestamp
1731 */
1732 private function get_category_last_update(string $category): ?string {
1733 $result = get_option("thinkrank_settings_{$category}_last_updated");
1734 return $result !== false ? $result : null;
1735 }
1736
1737 /**
1738 * Update settings metadata
1739 *
1740 * @since 1.0.0
1741 */
1742 private function update_settings_metadata(): void {
1743 update_option('thinkrank_settings_last_updated', current_time('mysql'));
1744
1745 // Increment version
1746 $current_version = $this->get_settings_version();
1747 $version_parts = explode('.', $current_version);
1748 $version_parts[2] = (int)$version_parts[2] + 1;
1749 $new_version = implode('.', $version_parts);
1750
1751 update_option('thinkrank_settings_version', $new_version);
1752 }
1753
1754 /**
1755 * Update category metadata
1756 *
1757 * @since 1.0.0
1758 *
1759 * @param string $category Category name
1760 */
1761 private function update_category_metadata(string $category): void {
1762 update_option("thinkrank_settings_{$category}_last_updated", current_time('mysql'));
1763 }
1764
1765 /**
1766 * Format export data
1767 *
1768 * @since 1.0.0
1769 *
1770 * @param array $export_data Export data
1771 * @param array $metadata Metadata
1772 * @param string $format Export format
1773 * @return string Formatted export data
1774 */
1775 private function format_export_data(array $export_data, array $metadata, string $format): string {
1776 $full_export = [
1777 'metadata' => $metadata,
1778 'settings' => $export_data
1779 ];
1780
1781 switch ($format) {
1782 case 'json':
1783 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1784 case 'yaml':
1785 // Would implement YAML formatting
1786 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1787 case 'xml':
1788 // Would implement XML formatting
1789 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1790 default:
1791 return wp_json_encode($full_export, JSON_PRETTY_PRINT);
1792 }
1793 }
1794
1795 /**
1796 * Parse import data
1797 *
1798 * @since 1.0.0
1799 *
1800 * @param string $import_data Import data
1801 * @param string $format Import format
1802 * @return array|false Parsed data or false on failure
1803 */
1804 private function parse_import_data(string $import_data, string $format) {
1805 switch ($format) {
1806 case 'json':
1807 $decoded = json_decode($import_data, true);
1808 return $decoded['settings'] ?? $decoded;
1809 case 'yaml':
1810 // Would implement YAML parsing
1811 $decoded = json_decode($import_data, true);
1812 return $decoded['settings'] ?? $decoded;
1813 case 'xml':
1814 // Would implement XML parsing
1815 $decoded = json_decode($import_data, true);
1816 return $decoded['settings'] ?? $decoded;
1817 default:
1818 return false;
1819 }
1820 }
1821
1822 /**
1823 * Save settings backup
1824 *
1825 * @since 1.0.0
1826 *
1827 * @param array $backup_data Backup data
1828 * @param array $backup_metadata Backup metadata
1829 * @return string|false Backup ID or false on failure
1830 */
1831 private function save_settings_backup(array $backup_data, array $backup_metadata) {
1832 $backup_id = uniqid('backup_', true);
1833
1834 $backup_record = [
1835 'backup_id' => $backup_id,
1836 'metadata' => $backup_metadata,
1837 'settings' => $backup_data
1838 ];
1839
1840 // Store as a NON-autoloaded option — each backup is a full multi-category
1841 // snapshot and must not be loaded into memory on every front-end/admin
1842 // request.
1843 $saved = update_option("thinkrank_backup_{$backup_id}", $backup_record, false);
1844
1845 if ($saved) {
1846 // Add to backup index (also non-autoloaded).
1847 $backup_index = get_option('thinkrank_backup_index', []);
1848 $backup_index[$backup_id] = $backup_metadata;
1849
1850 // Cap the retained set so the backups can't accumulate unbounded.
1851 $backup_index = $this->prune_settings_backups($backup_index);
1852
1853 update_option('thinkrank_backup_index', $backup_index, false);
1854
1855 return $backup_id;
1856 }
1857
1858 return false;
1859 }
1860
1861 /**
1862 * Keep only the most recent settings backups, deleting the option rows for
1863 * any pruned from the index (oldest first).
1864 *
1865 * @param array $backup_index backup_id => metadata map.
1866 * @return array Pruned index.
1867 */
1868 private function prune_settings_backups(array $backup_index): array {
1869 $max_backups = 10;
1870
1871 if (count($backup_index) <= $max_backups) {
1872 return $backup_index;
1873 }
1874
1875 // Oldest first (missing timestamps sort earliest).
1876 uasort($backup_index, static function ($a, $b) {
1877 return strcmp((string) ($a['created_at'] ?? ''), (string) ($b['created_at'] ?? ''));
1878 });
1879
1880 // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- the loop shrinks $backup_index, so the count has to be re-read.
1881 while (count($backup_index) > $max_backups) {
1882 $oldest_id = array_key_first($backup_index);
1883 unset($backup_index[$oldest_id]);
1884 delete_option("thinkrank_backup_{$oldest_id}");
1885 }
1886
1887 return $backup_index;
1888 }
1889
1890 /**
1891 * Load settings backup
1892 *
1893 * @since 1.0.0
1894 *
1895 * @param string $backup_id Backup ID
1896 * @return array|false Backup data or false on failure
1897 */
1898 private function load_settings_backup(string $backup_id) {
1899 return get_option("thinkrank_backup_{$backup_id}", false);
1900 }
1901
1902 /**
1903 * Argument validation methods
1904 */
1905
1906 /**
1907 * Get arguments for global settings endpoints
1908 *
1909 * @since 1.0.0
1910 *
1911 * @return array Arguments array
1912 */
1913 private function get_global_settings_args(): array {
1914 return [
1915 'settings' => [
1916 'required' => true,
1917 'type' => 'object',
1918 'description' => 'Global settings to update across categories'
1919 ],
1920 'validate' => [
1921 'required' => false,
1922 'type' => 'boolean',
1923 'default' => true,
1924 'description' => 'Whether to validate settings before updating'
1925 ]
1926 ];
1927 }
1928
1929 /**
1930 * Get arguments for category settings endpoints
1931 *
1932 * @since 1.0.0
1933 *
1934 * @return array Arguments array
1935 */
1936 private function get_category_settings_args(): array {
1937 return [
1938 'settings' => [
1939 'required' => true,
1940 'type' => 'object',
1941 'description' => 'Category settings to update'
1942 ],
1943 'validate' => [
1944 'required' => false,
1945 'type' => 'boolean',
1946 'default' => true,
1947 'description' => 'Whether to validate settings before updating'
1948 ],
1949 // Declared so the REST schema validates/normalises them. They were read
1950 // by the handler while undeclared, which skipped validation entirely (#367).
1951 'context_type' => [
1952 'required' => false,
1953 'type' => 'string',
1954 'enum' => ['site', 'post', 'page', 'product'],
1955 'default' => 'site',
1956 'description' => 'Object context these settings apply to'
1957 ],
1958 'context_id' => [
1959 'required' => false,
1960 'type' => 'integer',
1961 'minimum' => 1,
1962 'description' => 'Object ID when context_type is not "site"'
1963 ]
1964 ];
1965 }
1966
1967 /**
1968 * Authorise the object context a category settings write targets.
1969 *
1970 * The Settings section capability is delegatable, so a non-administrator can
1971 * reach this controller. Writing settings for a specific post is an edit of
1972 * that post and must be authorised as one — mirroring the per-object check the
1973 * social-media write route performs (#277, #367).
1974 *
1975 * @since 1.32.0
1976 *
1977 * @param string $context_type Requested context type.
1978 * @param int|null $context_id Requested object ID.
1979 * @return true|WP_Error True when the write is allowed, WP_Error otherwise.
1980 */
1981 private function authorize_settings_context(string $context_type, ?int $context_id) {
1982 if ('site' === $context_type) {
1983 return true;
1984 }
1985
1986 if (!in_array($context_type, ['post', 'page', 'product'], true)) {
1987 return new WP_Error(
1988 'invalid_context',
1989 'Invalid context type provided',
1990 ['status' => 400]
1991 );
1992 }
1993
1994 if (!$context_id || $context_id <= 0) {
1995 return new WP_Error(
1996 'invalid_context',
1997 'A valid context_id is required for non-site contexts',
1998 ['status' => 400]
1999 );
2000 }
2001
2002 $post = get_post($context_id);
2003
2004 if (!$post || 'revision' === $post->post_type) {
2005 return new WP_Error(
2006 'invalid_context',
2007 'The requested content could not be found',
2008 ['status' => 404]
2009 );
2010 }
2011
2012 // The declared context must match the one the front-end read path derives
2013 // from the real post type, otherwise `page`/`product` can alias an arbitrary
2014 // object and the row is written where nothing will ever read it. Mirrors
2015 // Seo_Manager::get_context_type() — custom post types fall back to 'post'.
2016 $expected_context = in_array($post->post_type, ['post', 'page', 'product'], true)
2017 ? $post->post_type
2018 : 'post';
2019
2020 if ($context_type !== $expected_context) {
2021 return new WP_Error(
2022 'invalid_context',
2023 'The context type does not match the requested content.',
2024 ['status' => 400]
2025 );
2026 }
2027
2028 if (!current_user_can('edit_post', $context_id)) {
2029 return new WP_Error(
2030 'rest_forbidden',
2031 'You are not allowed to edit settings for this content.',
2032 ['status' => 403]
2033 );
2034 }
2035
2036 return true;
2037 }
2038
2039 /**
2040 * Get arguments for validation endpoint
2041 *
2042 * @since 1.0.0
2043 *
2044 * @return array Arguments array
2045 */
2046 private function get_validation_args(): array {
2047 return [
2048 'settings' => [
2049 'required' => true,
2050 'type' => 'object',
2051 'description' => 'Settings to validate'
2052 ],
2053 'categories' => [
2054 'required' => false,
2055 'type' => 'array',
2056 'items' => [
2057 'type' => 'string',
2058 'enum' => array_keys($this->setting_categories)
2059 ],
2060 'description' => 'Categories to validate'
2061 ]
2062 ];
2063 }
2064
2065 /**
2066 * Get arguments for export endpoint
2067 *
2068 * @since 1.0.0
2069 *
2070 * @return array Arguments array
2071 */
2072 private function get_export_args(): array {
2073 return [
2074 'categories' => [
2075 'required' => false,
2076 'type' => 'array',
2077 'items' => [
2078 'type' => 'string',
2079 'enum' => array_keys($this->setting_categories)
2080 ],
2081 'description' => 'Categories to export'
2082 ],
2083 'format' => [
2084 'required' => false,
2085 'type' => 'string',
2086 'enum' => ['json', 'yaml', 'xml'],
2087 'default' => 'json',
2088 'description' => 'Export format'
2089 ],
2090 'include_metadata' => [
2091 'required' => false,
2092 'type' => 'boolean',
2093 'default' => true,
2094 'description' => 'Whether to include metadata in export'
2095 ]
2096 ];
2097 }
2098
2099 /**
2100 * Get arguments for import endpoint
2101 *
2102 * @since 1.0.0
2103 *
2104 * @return array Arguments array
2105 */
2106 private function get_import_args(): array {
2107 return [
2108 'import_data' => [
2109 'required' => true,
2110 'type' => 'string',
2111 'description' => 'Settings data to import'
2112 ],
2113 'format' => [
2114 'required' => false,
2115 'type' => 'string',
2116 'enum' => ['json', 'yaml', 'xml'],
2117 'default' => 'json',
2118 'description' => 'Import format'
2119 ],
2120 'validate' => [
2121 'required' => false,
2122 'type' => 'boolean',
2123 'default' => true,
2124 'description' => 'Whether to validate before importing'
2125 ],
2126 'overwrite_existing' => [
2127 'required' => false,
2128 'type' => 'boolean',
2129 'default' => false,
2130 'description' => 'Whether to overwrite existing settings'
2131 ]
2132 ];
2133 }
2134
2135 /**
2136 * Get arguments for backup endpoint
2137 *
2138 * @since 1.0.0
2139 *
2140 * @return array Arguments array
2141 */
2142 private function get_backup_args(): array {
2143 return [
2144 'backup_name' => [
2145 'required' => false,
2146 'type' => 'string',
2147 'description' => 'Name for the backup'
2148 ],
2149 'categories' => [
2150 'required' => false,
2151 'type' => 'array',
2152 'items' => [
2153 'type' => 'string',
2154 'enum' => array_keys($this->setting_categories)
2155 ],
2156 'description' => 'Categories to backup'
2157 ],
2158 'description' => [
2159 'required' => false,
2160 'type' => 'string',
2161 'description' => 'Backup description'
2162 ]
2163 ];
2164 }
2165
2166 /**
2167 * Get arguments for restore endpoint
2168 *
2169 * @since 1.0.0
2170 *
2171 * @return array Arguments array
2172 */
2173 private function get_restore_args(): array {
2174 return [
2175 'backup_id' => [
2176 'required' => true,
2177 'type' => 'string',
2178 'description' => 'Backup ID to restore from'
2179 ],
2180 'categories' => [
2181 'required' => false,
2182 'type' => 'array',
2183 'items' => [
2184 'type' => 'string',
2185 'enum' => array_keys($this->setting_categories)
2186 ],
2187 'description' => 'Categories to restore'
2188 ],
2189 'create_restore_point' => [
2190 'required' => false,
2191 'type' => 'boolean',
2192 'default' => true,
2193 'description' => 'Whether to create restore point before restoring'
2194 ]
2195 ];
2196 }
2197
2198 /**
2199 * Get arguments for reset endpoint
2200 *
2201 * @since 1.0.0
2202 *
2203 * @return array Arguments array
2204 */
2205 private function get_reset_args(): array {
2206 return [
2207 'categories' => [
2208 'required' => false,
2209 'type' => 'array',
2210 'items' => [
2211 'type' => 'string',
2212 'enum' => array_keys($this->setting_categories)
2213 ],
2214 'description' => 'Categories to reset'
2215 ],
2216 'create_backup' => [
2217 'required' => false,
2218 'type' => 'boolean',
2219 'default' => true,
2220 'description' => 'Whether to create backup before reset'
2221 ]
2222 ];
2223 }
2224
2225 /**
2226 * Snapshot the given categories' current settings into a persisted backup.
2227 *
2228 * Backs the pre-reset backup and restore-point features with real storage
2229 * (via save_settings_backup) instead of a fabricated id, so operators have a
2230 * genuine rollback snapshot before a destructive reset/restore.
2231 *
2232 * @param array $categories Categories to snapshot.
2233 * @param string $label Human-readable label for the backup.
2234 * @return string Backup id, or '' if the snapshot could not be persisted.
2235 */
2236 private function create_settings_snapshot(array $categories, string $label): string {
2237 $backup_data = [];
2238 foreach ($categories as $category) {
2239 if (isset($this->setting_categories[$category])) {
2240 $backup_data[$category] = $this->settings_manager->get_settings($category);
2241 }
2242 }
2243
2244 $backup_metadata = [
2245 'backup_name' => $label . ' ' . gmdate('Y-m-d_H-i-s'),
2246 'description' => $label,
2247 'created_at' => current_time('mysql'),
2248 'created_by' => get_current_user_id(),
2249 'categories' => $categories,
2250 'settings_version' => $this->get_settings_version(),
2251 'wordpress_version' => get_bloginfo('version'),
2252 'automatic' => true,
2253 ];
2254
2255 $backup_id = $this->save_settings_backup($backup_data, $backup_metadata);
2256
2257 return $backup_id ?: '';
2258 }
2259
2260 /**
2261 * Create a full-snapshot restore point before restoring a backup.
2262 *
2263 * @return string Backup id, or '' if it could not be persisted.
2264 */
2265 private function create_restore_point(): string {
2266 return $this->create_settings_snapshot(
2267 array_keys($this->setting_categories),
2268 'Automatic restore point'
2269 );
2270 }
2271
2272 /**
2273 * Create a safety backup of the given categories before a reset.
2274 *
2275 * @param array $categories Categories about to be reset.
2276 * @return string Backup id, or '' if it could not be persisted.
2277 */
2278 private function create_pre_reset_backup(array $categories): string {
2279 return $this->create_settings_snapshot($categories, 'Automatic pre-reset backup');
2280 }
2281 }
2282