PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 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.9.0, at includes/api/class-settings-management-endpoint.php

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