PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.1.0 1.10.0 All 48 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.7.0, at includes/api/class-settings-management-endpoint.php

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