PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 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 All 57 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 2.14.2, at includes/api/class-settings-management-endpoint.php

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