PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.32.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.32.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 1.32.0, at includes/api/class-settings-management-endpoint.php

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