PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/api/class-settings-management-endpoint.php +740 -407 1.0.0 → 2.10.0 View file →
@@ -28,8 +28,13 @@
28 28 use WP_REST_Request;
29 29 use WP_REST_Response;
30 30 use WP_Error;
31 31
32 +// Prevent direct access
33 +if (!defined('ABSPATH')) {
34 + exit;
35 +}
36 +
32 37 /**
33 38 * Settings Management API Endpoints Class
34 39 *
35 40 * Provides REST API endpoints for centralized settings management operations
@@ -48,14 +53,14 @@
48 53 */
49 54 private Settings_Manager $settings_manager;
50 55
51 56 /**
52 - * SEO Manager instances for integration
57 + * Lazily constructed SEO Manager instances, keyed by category
53 58 *
54 59 * @since 1.0.0
55 60 * @var array
56 61 */
57 - private array $seo_managers;
62 + private array $seo_managers = [];
58 63
59 64 /**
60 65 * API namespace
61 66 *
@@ -87,10 +92,15 @@
87 92 'social_media' => 'Social Media & Open Graph',
88 93 'sitemap' => 'XML Sitemap Management',
89 94 'integrations' => 'External Integrations',
90 95 'analytics_integration' => 'Analytics Integration',
91 - 'seo_analytics' => 'SEO Analytics & Intelligence',
92 - 'global_defaults' => 'Global Default Settings'
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).
93 103 ];
94 104
95 105 /**
96 106 * Constructor
@@ -98,23 +108,90 @@
98 108 * @since 1.0.0
99 109 */
100 110 public function __construct() {
101 111 $this->settings_manager = new Settings_Manager();
102 -
103 - // Initialize SEO manager instances for integration
104 - $this->seo_managers = [
105 - 'site_identity' => new Site_Identity_Manager(),
106 - 'performance_monitoring' => new Performance_Monitoring_Manager(),
107 - 'ai_content_analyzer' => new AI_Content_Analyzer(),
108 - 'content_optimization' => new Content_Optimization_Manager(),
109 - 'schema_management' => new Schema_Management_System(),
110 - 'social_media' => new Social_Meta_Manager(),
111 - 'sitemap' => new Sitemap_Generator(),
112 - 'analytics_integration' => new Performance_Monitoring_Manager()
113 - ];
114 112 }
115 113
116 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 + /**
117 194 * Register API routes
118 195 *
119 196 * @since 1.0.0
120 197 */
@@ -211,9 +288,9 @@
211 288 [
212 289 [
213 290 'methods' => 'POST',
214 291 'callback' => [$this, 'import_settings'],
215 - 'permission_callback' => [$this, 'check_manage_permissions'],
292 + 'permission_callback' => [$this, 'check_admin_permissions'],
216 293 'args' => $this->get_import_args()
217 294 ]
218 295 ]
219 296 );
@@ -244,27 +321,8 @@
244 321 ]
245 322 ]
246 323 );
247 324
248 - // Settings conflicts resolution
249 - register_rest_route(
250 - $this->namespace,
251 - '/' . $this->rest_base . '/conflicts',
252 - [
253 - [
254 - 'methods' => 'GET',
255 - 'callback' => [$this, 'detect_settings_conflicts'],
256 - 'permission_callback' => [$this, 'check_read_permissions']
257 - ],
258 - [
259 - 'methods' => 'POST',
260 - 'callback' => [$this, 'resolve_settings_conflicts'],
261 - 'permission_callback' => [$this, 'check_manage_permissions'],
262 - 'args' => $this->get_conflict_resolution_args()
263 - ]
264 - ]
265 - );
266 -
267 325 // Settings reset
268 326 register_rest_route(
269 327 $this->namespace,
270 328 '/' . $this->rest_base . '/reset',
@@ -271,28 +329,14 @@
271 329 [
272 330 [
273 331 'methods' => 'POST',
274 332 'callback' => [$this, 'reset_settings'],
275 - 'permission_callback' => [$this, 'check_manage_permissions'],
333 + 'permission_callback' => [$this, 'check_admin_permissions'],
276 334 'args' => $this->get_reset_args()
277 335 ]
278 336 ]
279 337 );
280 338
281 - // Bulk operations
282 - register_rest_route(
283 - $this->namespace,
284 - '/' . $this->rest_base . '/bulk',
285 - [
286 - [
287 - 'methods' => 'POST',
288 - 'callback' => [$this, 'bulk_operations'],
289 - 'permission_callback' => [$this, 'check_manage_permissions'],
290 - 'args' => $this->get_bulk_operations_args()
291 - ]
292 - ]
293 - );
294 -
295 339 // Database maintenance operations
296 340 register_rest_route(
297 341 $this->namespace,
298 342 '/' . $this->rest_base . '/maintenance/performance-indexes',
@@ -299,9 +343,9 @@
299 343 [
300 344 [
301 345 'methods' => 'POST',
302 346 'callback' => [$this, 'add_performance_indexes'],
303 - 'permission_callback' => [$this, 'check_manage_permissions']
347 + 'permission_callback' => [$this, 'check_admin_permissions']
304 348 ]
305 349 ]
306 350 );
307 351 }
@@ -306,8 +350,166 @@
306 350 );
307 351 }
308 352
309 353 /**
354 + * Setting keys that hold secrets (encrypted at rest).
355 + *
356 + * Mirrors ThinkRank\Core\Settings::$encrypted_keys — keep in sync. These must
357 + * never be returned decrypted from the read/export endpoints.
358 + *
359 + * @var string[]
360 + */
361 + private const SENSITIVE_SETTING_KEYS = [
362 + 'openai_api_key',
363 + 'claude_api_key',
364 + 'gemini_api_key',
365 + 'openrouter_api_key',
366 + 'openai_compatible_api_key',
367 + 'google_analytics_api_key',
368 + 'google_search_console_api_key',
369 + 'google_pagespeed_api_key',
370 + 'google_access_token',
371 + 'google_refresh_token',
372 + 'pinterest_site_verification',
373 + 'instagram_verification',
374 + 'tiktok_verification',
375 + ];
376 +
377 + /**
378 + * Mask a secret value for display: keeps a "has value" signal and the last
379 + * four characters, never the secret itself. Empty stays empty.
380 + *
381 + * @param mixed $value Raw setting value.
382 + * @return string Masked value.
383 + */
384 + private function mask_secret_value($value): string {
385 + if (!is_string($value) || $value === '') {
386 + return '';
387 + }
388 + $suffix = strlen($value) > 4 ? substr($value, -4) : '';
389 + return '••••' . $suffix;
390 + }
391 +
392 + /**
393 + * Redact secrets from a category => settings map before it leaves the site.
394 + *
395 + * Read responses mask secrets (presence + last 4). Exports drop them entirely
396 + * so long-lived third-party credentials never land in an export file (and a
397 + * masked value can't corrupt the real key on re-import).
398 + *
399 + * @param array $settings category => [key => value] map.
400 + * @param bool $for_export Whether this is an export (drop) vs a read (mask).
401 + * @return array Redacted map.
402 + */
403 + private function redact_sensitive_settings(array $settings, bool $for_export = false): array {
404 + foreach ($settings as $category => $values) {
405 + if (!is_array($values)) {
406 + continue;
407 + }
408 + foreach ($values as $key => $value) {
409 + if (!in_array($key, self::SENSITIVE_SETTING_KEYS, true)) {
410 + continue;
411 + }
412 + if ($for_export) {
413 + unset($values[$key]);
414 + } else {
415 + $values[$key] = $this->mask_secret_value($value);
416 + }
417 + }
418 + $settings[$category] = $values;
419 + }
420 + return $settings;
421 + }
422 +
423 + /**
424 + * Redact secrets from a single category's flat key => value map.
425 + *
426 + * Convenience wrapper so the single-category response shapes get the same
427 + * treatment as the global map — no response path may return a cleartext
428 + * secret.
429 + *
430 + * @param string $category Category slug.
431 + * @param array $settings Flat key => value map for that category.
432 + * @return array Redacted flat map.
433 + */
434 + private function redact_category_settings(string $category, array $settings): array {
435 + $redacted = $this->redact_sensitive_settings([$category => $settings]);
436 + return $redacted[$category] ?? [];
437 + }
438 +
439 + /**
440 + * Drop masked secrets from an incoming write payload.
441 + *
442 + * Read responses return secrets masked ("••••abcd"). A client that GETs a
443 + * settings map and POSTs it straight back would otherwise persist the mask
444 + * over the real credential. Any sensitive key whose incoming value still
445 + * carries the mask marker is removed so the stored value is left untouched;
446 + * a genuinely new secret (no marker) writes through normally.
447 + *
448 + * @param array $settings Flat key => value map from the request.
449 + * @return array Map with masked secret values removed.
450 + */
451 + /**
452 + * Drop setting keys the category does not define.
453 + *
454 + * The known set is whatever describes the category: the generic store's key
455 + * list, and the dedicated manager's default settings when one owns it.
456 + * Fails open — if neither store can describe the category there is nothing
457 + * to check against, and silently dropping everything would be worse than
458 + * storing an unknown key.
459 + *
460 + * @since 2.0.1
461 + *
462 + * @param array $settings Incoming settings.
463 + * @param string $category Settings category.
464 + * @param string $context_type Context the write is scoped to.
465 + * @return array Settings limited to recognised keys.
466 + */
467 + private function filter_known_setting_keys(array $settings, string $category, string $context_type): array {
468 + $known = [];
469 +
470 + // $this->setting_categories maps category => label; the key lists live
471 + // in the generic store.
472 + $known = array_merge($known, $this->settings_manager->get_category_keys($category));
473 +
474 + if ($this->has_seo_manager($category)) {
475 + $known = array_merge(
476 + $known,
477 + array_keys($this->get_seo_manager($category)->get_default_settings($context_type))
478 + );
479 + }
480 +
481 + /**
482 + * Filter the setting keys a category accepts.
483 + *
484 + * @since 2.0.1
485 + *
486 + * @param string[] $known Recognised setting keys.
487 + * @param string $category Settings category.
488 + * @param string $context_type Context the write is scoped to.
489 + */
490 + $known = apply_filters('thinkrank_known_setting_keys', $known, $category, $context_type);
491 +
492 + if (empty($known)) {
493 + return $settings;
494 + }
495 +
496 + return array_intersect_key($settings, array_flip($known));
497 + }
498 +
499 + private function strip_masked_secrets(array $settings): array {
500 + foreach ($settings as $key => $value) {
501 + if (!in_array($key, self::SENSITIVE_SETTING_KEYS, true)) {
502 + continue;
503 + }
504 + if (is_string($value) && strpos($value, '••••') !== false) {
505 + unset($settings[$key]);
506 + }
507 + }
508 + return $settings;
509 + }
510 +
511 + /**
310 512 * Get global settings across all categories
311 513 *
312 514 * @since 1.0.0
313 515 *
@@ -326,15 +528,15 @@
326 528 if (!isset($this->setting_categories[$category])) {
327 529 continue;
328 530 }
329 531
330 - // Get settings for each category using Settings Manager
331 - $category_settings = $this->settings_manager->get_settings($category);
532 + // Get settings for each category from the store that owns it.
533 + $category_settings = $this->read_category($category);
332 534 $global_settings[$category] = $category_settings;
333 535
334 536 // Get schema if requested
335 - if ($include_schema && isset($this->seo_managers[$category])) {
336 - $settings_schema[$category] = $this->seo_managers[$category]->get_settings_schema($category);
537 + if ($include_schema && $this->has_seo_manager($category)) {
538 + $settings_schema[$category] = $this->get_seo_manager($category)->get_settings_schema($category);
337 539 }
338 540 }
339 541
340 542 // Get global metadata
@@ -347,9 +549,9 @@
347 549
348 550 return new WP_REST_Response([
349 551 'success' => true,
350 552 'data' => [
351 - 'settings' => $global_settings,
553 + 'settings' => $this->redact_sensitive_settings($global_settings),
352 554 'schema' => $settings_schema,
353 555 'metadata' => $metadata,
354 556 'categories' => $this->setting_categories
355 557 ],
@@ -385,8 +587,24 @@
385 587 ['status' => 400]
386 588 );
387 589 }
388 590
591 + // Each per-category value must be an array before it reaches the
592 + // strict array-typed manager methods; reject non-array values with a
593 + // 400 instead of letting them surface as an uncaught TypeError.
594 + foreach ($settings as $category => $category_settings) {
595 + if (!is_array($category_settings)) {
596 + return new WP_Error(
597 + 'invalid_settings',
598 + "Settings for category '{$category}' must be provided as an object",
599 + ['status' => 400]
600 + );
601 + }
602 +
603 + // Reads mask secrets; never persist a mask back over the real one.
604 + $settings[$category] = $this->strip_masked_secrets($category_settings);
605 + }
606 +
389 607 $validation_results = [];
390 608 $update_results = [];
391 609
392 610 // Validate all settings before updating if requested
@@ -395,10 +613,10 @@
395 613 if (!isset($this->setting_categories[$category])) {
396 614 continue;
397 615 }
398 616
399 - if (isset($this->seo_managers[$category])) {
400 - $validation = $this->seo_managers[$category]->validate_settings($category_settings);
617 + if ($this->has_seo_manager($category)) {
618 + $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
401 619 $validation_results[$category] = $validation;
402 620
403 621 if (!$validation['valid']) {
404 622 return new WP_Error(
@@ -424,10 +642,10 @@
424 642 // Update using Settings Manager
425 643 $update_success = $this->settings_manager->update_settings($category_settings, $category);
426 644
427 645 // Also update through specific SEO manager if available
428 - if (isset($this->seo_managers[$category])) {
429 - $manager_update = $this->seo_managers[$category]->save_settings('site', null, $category_settings);
646 + if ($this->has_seo_manager($category)) {
647 + $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
430 648 $update_success = $update_success && $manager_update;
431 649 }
432 650
433 651 $update_results[$category] = [
@@ -449,9 +667,9 @@
449 667 // Get updated settings
450 668 $updated_settings = [];
451 669 foreach (array_keys($settings) as $category) {
452 670 if (isset($this->setting_categories[$category])) {
453 - $updated_settings[$category] = $this->settings_manager->get_settings($category);
671 + $updated_settings[$category] = $this->read_category($category);
454 672 }
455 673 }
456 674
457 675 return new WP_REST_Response([
@@ -456,9 +674,9 @@
456 674
457 675 return new WP_REST_Response([
458 676 'success' => true,
459 677 'data' => [
460 - 'updated_settings' => $updated_settings,
678 + 'updated_settings' => $this->redact_sensitive_settings($updated_settings),
461 679 'validation_results' => $validation_results,
462 680 'update_results' => $update_results,
463 681 'settings_version' => $this->get_settings_version()
464 682 ],
@@ -496,14 +714,14 @@
496 714 );
497 715 }
498 716
499 717 // Get category settings
500 - $category_settings = $this->settings_manager->get_settings($category);
718 + $category_settings = $this->read_category($category);
501 719
502 720 // Get schema if requested
503 721 $schema = [];
504 - if ($include_schema && isset($this->seo_managers[$category])) {
505 - $schema = $this->seo_managers[$category]->get_settings_schema($category);
722 + if ($include_schema && $this->has_seo_manager($category)) {
723 + $schema = $this->get_seo_manager($category)->get_settings_schema($category);
506 724 }
507 725
508 726 // Get category metadata
509 727 $metadata = [
@@ -510,15 +728,15 @@
510 728 'category' => $category,
511 729 'category_name' => $this->setting_categories[$category],
512 730 'settings_count' => count($category_settings),
513 731 'last_updated' => $this->get_category_last_update($category),
514 - 'has_manager' => isset($this->seo_managers[$category])
732 + 'has_manager' => $this->has_seo_manager($category)
515 733 ];
516 734
517 735 return new WP_REST_Response([
518 736 'success' => true,
519 737 'data' => [
520 - 'settings' => $category_settings,
738 + 'settings' => $this->redact_category_settings($category, $category_settings),
521 739 'schema' => $schema,
522 740 'metadata' => $metadata
523 741 ],
524 742 'message' => "Settings for category '{$category}' retrieved successfully"
@@ -573,13 +791,46 @@
573 791 ['status' => 400]
574 792 );
575 793 }
576 794
795 + // SECURITY: this route also accepts an object context and forwards it
796 + // to the category's SEO manager, which upserts rows keyed by that ID.
797 + // The `thinkrank_settings` capability authorises entry to the Settings
798 + // section — it is not authorisation to edit every post on the site — so
799 + // resolve and authorise the object before ANY write happens below (#367).
800 + $context_type = $request->get_param('context_type') ?? 'site';
801 + $context_id = $request->get_param('context_id');
802 + $context_id = null === $context_id ? null : (int) $context_id;
803 +
804 + $context_error = $this->authorize_settings_context($context_type, $context_id);
805 + if (is_wp_error($context_error)) {
806 + return $context_error;
807 + }
808 +
809 + // Reads mask secrets; never persist a mask back over the real one.
810 + $settings = $this->strip_masked_secrets($settings);
811 +
812 + // Drop keys the category does not define. This route persisted any
813 + // key it was handed — a probe key written through it is still
814 + // readable in the settings table afterwards — which bloats the
815 + // store and lets a client invent settings the plugin will never
816 + // read (#395). Mirrors the same guard on the schema and
817 + // social-media routes.
818 + $settings = $this->filter_known_setting_keys($settings, $category, $context_type);
819 +
820 + if (empty($settings)) {
821 + return new WP_Error(
822 + 'invalid_settings',
823 + "No recognized settings were provided for category: {$category}",
824 + ['status' => 400]
825 + );
826 + }
827 +
577 828 $validation_result = ['valid' => true];
578 829
579 830 // Validate settings if requested
580 - if ($validate_before_update && isset($this->seo_managers[$category])) {
581 - $validation_result = $this->seo_managers[$category]->validate_settings($settings);
831 + if ($validate_before_update && $this->has_seo_manager($category)) {
832 + $validation_result = $this->get_seo_manager($category)->validate_settings($settings);
582 833
583 834 if (!$validation_result['valid']) {
584 835 return new WP_Error(
585 836 'validation_failed',
@@ -592,38 +843,127 @@
592 843 );
593 844 }
594 845 }
595 846
596 - // Update settings
597 - $update_success = $this->settings_manager->update_settings($settings, $category);
847 + // Update settings. The context must be forwarded: update_settings()
848 + // defaults to the 'site' context, so a post-scoped request was also
849 + // silently rewriting the site-wide defaults (#367).
850 + $generic_update = $this->settings_manager->update_settings($settings, $category, $context_type, $context_id);
851 + $manager_update = null;
598 852
599 - // Also update through specific SEO manager if available
600 - if (isset($this->seo_managers[$category])) {
601 - $context_type = $request->get_param('context_type') ?? 'site';
602 - $context_id = $request->get_param('context_id') ?? null;
603 - $manager_update = $this->seo_managers[$category]->save_settings($context_type, $context_id, $settings);
604 - $update_success = $update_success && $manager_update;
853 + // Also update through specific SEO manager if available. The context was
854 + // resolved and authorised above.
855 + if ($this->has_seo_manager($category)) {
856 + $manager_update = $this->get_seo_manager($category)->save_settings($context_type, $context_id, $settings);
605 857 }
606 858
859 + // null from a store means "this category is not mine", not "the write
860 + // failed" — the two registries use different category vocabularies, so
861 + // most categories are owned by exactly one store (#371). Judge only the
862 + // stores that actually attempted a write: the save succeeded if at least
863 + // one store owned the category and none of the owners failed. ANDing the
864 + // raw values reported 500 for every category the generic store does not
865 + // know, while the dedicated manager's row had already committed.
866 + $attempted = array_filter(
867 + [$generic_update, $manager_update],
868 + static fn($result) => null !== $result
869 + );
870 +
871 + $update_success = [] !== $attempted && !in_array(false, $attempted, true);
872 +
607 873 if (!$update_success) {
874 + // Name the settings that did not persist. The write is not
875 + // transactional, so "failed" can mean some keys saved and others
876 + // did not — without the list the UI can only show a generic
877 + // error and the user has no idea what to re-enter (#300).
878 + $failed_keys = $this->settings_manager->get_last_failed_keys();
879 +
880 + // Report which store failed. Collapsing both writes into one boolean
881 + // meant a committed manager row could be reported as a total failure,
882 + // hiding a persisted change behind a 500 (#367). Only a literal false
883 + // is a failure — null means the store does not own this category and
884 + // never attempted a write, so it must not be named here (#371).
885 + $stores_failed = [];
886 + if (false === $generic_update) {
887 + $stores_failed[] = 'settings';
888 + }
889 + if (false === $manager_update) {
890 + $stores_failed[] = 'category_manager';
891 + }
892 +
893 + // No store owns the category. That is a routing defect rather than a
894 + // failed write, and it is worth distinguishing: the settings were
895 + // never persisted anywhere, so reporting it as a plain write failure
896 + // would send the user back to re-enter values that have nowhere to go.
897 + if ([] === $attempted) {
898 + return new WP_Error(
899 + 'category_not_persistable',
900 + sprintf(
901 + 'No settings store is registered for category %s, so nothing was saved.',
902 + $category
903 + ),
904 + [
905 + 'status' => 500,
906 + 'failed_keys' => $failed_keys,
907 + 'stores_failed' => $stores_failed,
908 + 'partial_write' => false,
909 + ]
910 + );
911 + }
912 +
608 913 return new WP_Error(
609 914 'update_failed',
610 - "Failed to update settings for category: {$category}",
611 - ['status' => 500]
915 + empty($failed_keys)
916 + ? "Failed to update settings for category: {$category}"
917 + : sprintf(
918 + 'Failed to save %s in category %s. Other settings in this request were saved.',
919 + implode(', ', $failed_keys),
920 + $category
921 + ),
922 + [
923 + 'status' => 500,
924 + 'failed_keys' => $failed_keys,
925 + 'stores_failed' => $stores_failed,
926 + // True when more than one store attempted the write and they
927 + // disagreed, so the client knows the request was not a clean
928 + // no-op. Stores that did not own the category are excluded.
929 + 'partial_write' => in_array(true, $attempted, true)
930 + && in_array(false, $attempted, true),
931 + ]
612 932 );
613 933 }
614 934
935 + // Clear analytics cache when GSC/GA settings change so fresh data is fetched
936 + if ($category === 'seo_analytics') {
937 + foreach (['7d', '30d', '90d'] as $range) {
938 + delete_transient("analytics_dashboard_v5_{$range}");
939 + delete_transient("seo_opportunities_{$range}");
940 + delete_transient("seo_insights_{$range}");
941 + }
942 + delete_transient('indexing_status');
943 + }
944 +
615 945 // Update category metadata
616 946 $this->update_category_metadata($category);
617 947
618 - // Get updated settings
619 - $updated_settings = $this->settings_manager->get_settings($category);
948 + // Get updated settings. Read them back from whichever store actually
949 + // owns the category: the generic store returns [] for the categories it
950 + // does not know, which would report a successful save as zero settings
951 + // and hand the UI an empty form to render (#371).
952 + //
953 + // Which store *accepted the write* is the wrong question to ask here,
954 + // and `sitemap` is the case that proves it: the generic store claims
955 + // that write (update_settings() returns true, not null) and then reads
956 + // the category back as [], so keying off $generic_update sent the one
957 + // read path that had been fixed straight back into the empty store.
958 + // Ownership is a property of the category, not of the last write (#689).
959 + $updated_settings = $this->read_category($category, $context_type, $context_id);
620 960
621 961 return new WP_REST_Response([
622 962 'success' => true,
623 963 'data' => [
624 964 'category' => $category,
625 - 'updated_settings' => $updated_settings,
965 + 'updated_settings' => $this->redact_category_settings($category, $updated_settings),
626 966 'validation_result' => $validation_result,
627 967 'settings_count' => count($updated_settings)
628 968 ],
629 969 'message' => "Settings for category '{$category}' updated successfully"
@@ -648,8 +988,11 @@
648 988 */
649 989 public function validate_settings(WP_REST_Request $request): WP_REST_Response {
650 990 try {
651 991 $settings = $request->get_param('settings');
992 + if (!is_array($settings)) {
993 + $settings = [];
994 + }
652 995 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
653 996
654 997 $validation_results = [];
655 998 $overall_valid = true;
@@ -659,11 +1002,21 @@
659 1002 continue;
660 1003 }
661 1004
662 1005 $category_settings = $settings[$category] ?? [];
1006 + if (!is_array($category_settings)) {
1007 + $validation_results[$category] = [
1008 + 'valid' => false,
1009 + 'errors' => ['Settings for this category must be an object'],
1010 + 'warnings' => [],
1011 + 'suggestions' => [],
1012 + ];
1013 + $overall_valid = false;
1014 + continue;
1015 + }
663 1016
664 - if (isset($this->seo_managers[$category])) {
665 - $validation = $this->seo_managers[$category]->validate_settings($category_settings);
1017 + if ($this->has_seo_manager($category)) {
1018 + $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
666 1019 $validation_results[$category] = $validation;
667 1020
668 1021 if (!$validation['valid']) {
669 1022 $overall_valid = false;
@@ -678,17 +1031,13 @@
678 1031 ];
679 1032 }
680 1033 }
681 1034
682 - // Check for cross-category conflicts
683 - $conflict_analysis = $this->analyze_cross_category_conflicts($settings);
684 -
685 1035 return new WP_REST_Response([
686 1036 'success' => true,
687 1037 'data' => [
688 1038 'validation_results' => $validation_results,
689 1039 'overall_valid' => $overall_valid,
690 - 'conflict_analysis' => $conflict_analysis,
691 1040 'validated_categories' => count($validation_results),
692 1041 'validation_timestamp' => current_time('mysql')
693 1042 ],
694 1043 'message' => 'Settings validation completed'
@@ -720,12 +1069,12 @@
720 1069 if (!isset($this->setting_categories[$category])) {
721 1070 continue;
722 1071 }
723 1072
724 - if (isset($this->seo_managers[$category])) {
1073 + if ($this->has_seo_manager($category)) {
725 1074 $schema_data[$category] = [
726 - 'schema' => $this->seo_managers[$category]->get_settings_schema($category),
727 - 'defaults' => $this->seo_managers[$category]->get_default_settings($category),
1075 + 'schema' => $this->get_seo_manager($category)->get_settings_schema($category),
1076 + 'defaults' => $this->get_seo_manager($category)->get_default_settings($category),
728 1077 'category_name' => $this->setting_categories[$category]
729 1078 ];
730 1079 } else {
731 1080 $schema_data[$category] = [
@@ -785,11 +1134,15 @@
785 1134 if (!isset($this->setting_categories[$category])) {
786 1135 continue;
787 1136 }
788 1137
789 - $export_data[$category] = $this->settings_manager->get_settings($category);
1138 + $export_data[$category] = $this->read_category($category);
790 1139 }
791 1140
1141 + // Never let secrets (API keys, OAuth tokens) leave the site in an
1142 + // export file — strip them entirely.
1143 + $export_data = $this->redact_sensitive_settings($export_data, true);
1144 +
792 1145 // Add metadata if requested
793 1146 $metadata = [];
794 1147 if ($include_metadata) {
795 1148 $metadata = [
@@ -795,9 +1148,9 @@
795 1148 $metadata = [
796 1149 'export_timestamp' => current_time('mysql'),
797 1150 'export_version' => $this->get_settings_version(),
798 1151 'wordpress_version' => get_bloginfo('version'),
799 - 'thinkrank_version' => '1.0.0',
1152 + 'thinkrank_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '',
800 1153 'site_url' => home_url(),
801 1154 'exported_categories' => $categories
802 1155 ];
803 1156 }
@@ -859,8 +1212,28 @@
859 1212 ['status' => 400]
860 1213 );
861 1214 }
862 1215
1216 + if (!is_array($parsed_data)) {
1217 + return new WP_Error(
1218 + 'invalid_import_data',
1219 + 'Import data must be an object of settings categories',
1220 + ['status' => 400]
1221 + );
1222 + }
1223 +
1224 + // Reject non-array per-category values before they reach the strict
1225 + // array-typed manager methods (avoids an uncaught TypeError).
1226 + foreach ($parsed_data as $category => $category_settings) {
1227 + if (!is_array($category_settings)) {
1228 + return new WP_Error(
1229 + 'invalid_import_data',
1230 + "Settings for category '{$category}' must be an object",
1231 + ['status' => 400]
1232 + );
1233 + }
1234 + }
1235 +
863 1236 $import_results = [];
864 1237 $validation_results = [];
865 1238
866 1239 // Validate imported settings if requested
@@ -869,10 +1242,10 @@
869 1242 if (!isset($this->setting_categories[$category])) {
870 1243 continue;
871 1244 }
872 1245
873 - if (isset($this->seo_managers[$category])) {
874 - $validation = $this->seo_managers[$category]->validate_settings($category_settings);
1246 + if ($this->has_seo_manager($category)) {
1247 + $validation = $this->get_seo_manager($category)->validate_settings($category_settings);
875 1248 $validation_results[$category] = $validation;
876 1249
877 1250 if (!$validation['valid']) {
878 1251 return new WP_Error(
@@ -899,9 +1272,9 @@
899 1272 }
900 1273
901 1274 try {
902 1275 // Check if settings exist and handle overwrite
903 - $existing_settings = $this->settings_manager->get_settings($category);
1276 + $existing_settings = $this->read_category($category);
904 1277
905 1278 if (!empty($existing_settings) && !$overwrite_existing) {
906 1279 $import_results[$category] = [
907 1280 'success' => false,
@@ -913,10 +1286,10 @@
913 1286 // Import settings
914 1287 $import_success = $this->settings_manager->update_settings($category_settings, $category);
915 1288
916 1289 // Also update through specific SEO manager if available
917 - if (isset($this->seo_managers[$category])) {
918 - $manager_update = $this->seo_managers[$category]->save_settings('site', null, $category_settings);
1290 + if ($this->has_seo_manager($category)) {
1291 + $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
919 1292 $import_success = $import_success && $manager_update;
920 1293 }
921 1294
922 1295 $import_results[$category] = [
@@ -974,9 +1347,9 @@
974 1347 // Create backup data
975 1348 $backup_data = [];
976 1349 foreach ($categories as $category) {
977 1350 if (isset($this->setting_categories[$category])) {
978 - $backup_data[$category] = $this->settings_manager->get_settings($category);
1351 + $backup_data[$category] = $this->read_category($category);
979 1352 }
980 1353 }
981 1354
982 1355 // Create backup metadata
@@ -1054,12 +1427,20 @@
1054 1427 ['status' => 404]
1055 1428 );
1056 1429 }
1057 1430
1058 - // Create restore point if requested
1431 + // Create restore point if requested. Abort if it couldn't be saved,
1432 + // so the current configuration isn't overwritten with no rollback.
1059 1433 $restore_point_id = null;
1060 1434 if ($create_restore_point) {
1061 1435 $restore_point_id = $this->create_restore_point();
1436 + if ($restore_point_id === '') {
1437 + return new WP_Error(
1438 + 'restore_point_failed',
1439 + 'Could not create a restore point; aborting restore to avoid unrecoverable settings loss.',
1440 + ['status' => 500]
1441 + );
1442 + }
1062 1443 }
1063 1444
1064 1445 $restore_results = [];
1065 1446
@@ -1082,10 +1463,10 @@
1082 1463 // Restore settings
1083 1464 $restore_success = $this->settings_manager->update_settings($category_settings, $category);
1084 1465
1085 1466 // Also update through specific SEO manager if available
1086 - if (isset($this->seo_managers[$category])) {
1087 - $manager_update = $this->seo_managers[$category]->save_settings('site', null, $category_settings);
1467 + if ($this->has_seo_manager($category)) {
1468 + $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
1088 1469 $restore_success = $restore_success && $manager_update;
1089 1470 }
1090 1471
1091 1472 $restore_results[$category] = [
@@ -1125,112 +1506,8 @@
1125 1506 }
1126 1507 }
1127 1508
1128 1509 /**
1129 - * Detect settings conflicts
1130 - *
1131 - * @since 1.0.0
1132 - *
1133 - * @param WP_REST_Request $request Request object
1134 - * @return WP_REST_Response Response object
1135 - */
1136 - public function detect_settings_conflicts(WP_REST_Request $request): WP_REST_Response {
1137 - try {
1138 - $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1139 -
1140 - // Get all settings for analysis
1141 - $all_settings = [];
1142 - foreach ($categories as $category) {
1143 - if (isset($this->setting_categories[$category])) {
1144 - $all_settings[$category] = $this->settings_manager->get_settings($category);
1145 - }
1146 - }
1147 -
1148 - // Analyze conflicts
1149 - $conflicts = $this->analyze_cross_category_conflicts($all_settings);
1150 -
1151 - // Get conflict resolution suggestions
1152 - $resolution_suggestions = $this->generate_conflict_resolution_suggestions($conflicts);
1153 -
1154 - return new WP_REST_Response([
1155 - 'success' => true,
1156 - 'data' => [
1157 - 'conflicts' => $conflicts,
1158 - 'resolution_suggestions' => $resolution_suggestions,
1159 - 'analyzed_categories' => count($all_settings),
1160 - 'conflict_count' => count($conflicts),
1161 - 'analysis_timestamp' => current_time('mysql')
1162 - ],
1163 - 'message' => 'Settings conflicts analysis completed'
1164 - ], 200);
1165 -
1166 - } catch (\Exception $e) {
1167 - return new WP_REST_Response([
1168 - 'success' => false,
1169 - 'error' => 'Conflict detection failed: ' . $e->getMessage()
1170 - ], 500);
1171 - }
1172 - }
1173 -
1174 - /**
1175 - * Resolve settings conflicts
1176 - *
1177 - * @since 1.0.0
1178 - *
1179 - * @param WP_REST_Request $request Request object
1180 - * @return WP_REST_Response|WP_Error Response object or error
1181 - */
1182 - public function resolve_settings_conflicts(WP_REST_Request $request) {
1183 - try {
1184 - $resolutions = $request->get_param('resolutions');
1185 -
1186 - // Validate resolutions
1187 - if (empty($resolutions) || !is_array($resolutions)) {
1188 - return new WP_Error(
1189 - 'invalid_resolutions',
1190 - 'Conflict resolutions must be provided as an array',
1191 - ['status' => 400]
1192 - );
1193 - }
1194 -
1195 - $resolution_results = [];
1196 -
1197 - foreach ($resolutions as $resolution) {
1198 - $conflict_id = $resolution['conflict_id'] ?? '';
1199 - $resolution_action = $resolution['action'] ?? '';
1200 - $resolution_data = $resolution['data'] ?? [];
1201 -
1202 - try {
1203 - $result = $this->apply_conflict_resolution($conflict_id, $resolution_action, $resolution_data);
1204 - $resolution_results[$conflict_id] = $result;
1205 - } catch (\Exception $e) {
1206 - $resolution_results[$conflict_id] = [
1207 - 'success' => false,
1208 - 'error' => $e->getMessage()
1209 - ];
1210 - }
1211 - }
1212 -
1213 - return new WP_REST_Response([
1214 - 'success' => true,
1215 - 'data' => [
1216 - 'resolution_results' => $resolution_results,
1217 - 'resolved_conflicts' => count($resolution_results),
1218 - 'resolution_timestamp' => current_time('mysql')
1219 - ],
1220 - 'message' => 'Settings conflicts resolution completed'
1221 - ], 200);
1222 -
1223 - } catch (\Exception $e) {
1224 - return new WP_Error(
1225 - 'resolution_failed',
1226 - 'Conflict resolution failed: ' . $e->getMessage(),
1227 - ['status' => 500]
1228 - );
1229 - }
1230 - }
1231 -
1232 - /**
1233 1510 * Reset settings to defaults
1234 1511 *
1235 1512 * @since 1.0.0
1236 1513 *
@@ -1241,12 +1518,21 @@
1241 1518 try {
1242 1519 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1243 1520 $create_backup = $request->get_param('create_backup') ?? true;
1244 1521
1245 - // Create backup before reset if requested
1522 + // Create backup before reset if requested. If the backup was asked
1523 + // for but couldn't be persisted, abort rather than silently wiping
1524 + // settings with no rollback — the whole point of the flag is safety.
1246 1525 $backup_id = null;
1247 1526 if ($create_backup) {
1248 1527 $backup_id = $this->create_pre_reset_backup($categories);
1528 + if ($backup_id === '') {
1529 + return new WP_Error(
1530 + 'backup_failed',
1531 + 'Could not create a pre-reset backup; aborting reset to avoid unrecoverable settings loss.',
1532 + ['status' => 500]
1533 + );
1534 + }
1249 1535 }
1250 1536
1251 1537 $reset_results = [];
1252 1538
@@ -1257,10 +1543,10 @@
1257 1543
1258 1544 try {
1259 1545 // Get default settings
1260 1546 $default_settings = [];
1261 - if (isset($this->seo_managers[$category])) {
1262 - $default_settings = $this->seo_managers[$category]->get_default_settings($category);
1547 + if ($this->has_seo_manager($category)) {
1548 + $default_settings = $this->get_seo_manager($category)->get_default_settings($category);
1263 1549 }
1264 1550
1265 1551 // Reset to defaults
1266 1552 $reset_success = $this->settings_manager->update_settings($default_settings, $category);
@@ -1265,10 +1551,10 @@
1265 1551 // Reset to defaults
1266 1552 $reset_success = $this->settings_manager->update_settings($default_settings, $category);
1267 1553
1268 1554 // Also reset through specific SEO manager if available
1269 - if (isset($this->seo_managers[$category])) {
1270 - $manager_reset = $this->seo_managers[$category]->save_settings('site', null, $default_settings);
1555 + if ($this->has_seo_manager($category)) {
1556 + $manager_reset = $this->get_seo_manager($category)->save_settings('site', null, $default_settings);
1271 1557 $reset_success = $reset_success && $manager_reset;
1272 1558 }
1273 1559
1274 1560 $reset_results[$category] = [
@@ -1307,88 +1593,8 @@
1307 1593 }
1308 1594 }
1309 1595
1310 1596 /**
1311 - * Bulk operations for settings management
1312 - *
1313 - * @since 1.0.0
1314 - *
1315 - * @param WP_REST_Request $request Request object
1316 - * @return WP_REST_Response|WP_Error Response object or error
1317 - */
1318 - public function bulk_operations(WP_REST_Request $request) {
1319 - try {
1320 - $operation = $request->get_param('operation');
1321 - $items = $request->get_param('items') ?? [];
1322 - $options = $request->get_param('options') ?? [];
1323 -
1324 - // Validate input
1325 - if (empty($operation) || empty($items)) {
1326 - return new WP_Error(
1327 - 'missing_parameters',
1328 - 'Operation and items are required',
1329 - ['status' => 400]
1330 - );
1331 - }
1332 -
1333 - $results = [];
1334 - $errors = [];
1335 -
1336 - foreach ($items as $item) {
1337 - try {
1338 - switch ($operation) {
1339 - case 'validate_settings':
1340 - $result = $this->validate_category_settings_bulk($item);
1341 - break;
1342 - case 'update_settings':
1343 - $result = $this->update_category_settings_bulk($item);
1344 - break;
1345 - case 'export_settings':
1346 - $result = $this->export_category_settings_bulk($item);
1347 - break;
1348 - case 'reset_settings':
1349 - $result = $this->reset_category_settings_bulk($item);
1350 - break;
1351 - default:
1352 - throw new \Exception("Unsupported operation: {$operation}");
1353 - }
1354 -
1355 - $results[] = [
1356 - 'item' => $item,
1357 - 'success' => true,
1358 - 'data' => $result
1359 - ];
1360 -
1361 - } catch (\Exception $e) {
1362 - $errors[] = [
1363 - 'item' => $item,
1364 - 'error' => $e->getMessage()
1365 - ];
1366 - }
1367 - }
1368 -
1369 - return new WP_REST_Response([
1370 - 'success' => empty($errors),
1371 - 'data' => [
1372 - 'results' => $results,
1373 - 'errors' => $errors,
1374 - 'total_processed' => count($items),
1375 - 'successful' => count($results),
1376 - 'failed' => count($errors)
1377 - ],
1378 - 'message' => "Bulk {$operation} operation completed"
1379 - ], 200);
1380 -
1381 - } catch (\Exception $e) {
1382 - return new WP_Error(
1383 - 'bulk_operation_failed',
1384 - 'Bulk operation failed: ' . $e->getMessage(),
1385 - ['status' => 500]
1386 - );
1387 - }
1388 - }
1389 -
1390 - /**
1391 1597 * Add performance indexes to database tables
1392 1598 *
1393 1599 * @since 1.0.0
1394 1600 *
@@ -1394,9 +1600,9 @@
1394 1600 *
1395 1601 * @param WP_REST_Request $request Request object
1396 1602 * @return WP_REST_Response|WP_Error Response object
1397 1603 */
1398 - public function add_performance_indexes(WP_REST_Request $request): WP_REST_Response|WP_Error {
1604 + public function add_performance_indexes(WP_REST_Request $request) {
1399 1605 try {
1400 1606 // Import the Database_Schema class
1401 1607 if (!class_exists('ThinkRank\\Database\\Database_Schema')) {
1402 1608 require_once THINKRANK_PLUGIN_DIR . 'includes/database/class-database-schema.php';
@@ -1444,13 +1650,49 @@
1444 1650 * @since 1.0.0
1445 1651 *
1446 1652 * @return bool Permission status
1447 1653 */
1448 - public function check_read_permissions(): bool {
1449 - return current_user_can('read');
1654 + public function check_read_permissions(WP_REST_Request $request): bool {
1655 + // Plugin SEO/AI config is not subscriber-visible — require the same
1656 + // management capability as the write routes, resolved per category so a
1657 + // role granted one section can reach that section and no other (#573).
1658 + return \ThinkRank\Core\Capability_Manager::current_user_can(
1659 + $this->capability_for_request($request)
1660 + );
1450 1661 }
1451 1662
1452 1663 /**
1664 + * The capability a settings-management request requires.
1665 + *
1666 + * Category routes belong to the section owning the category; every other
1667 + * route on this controller is plugin-wide configuration and stays on
1668 + * `thinkrank_settings`. The gate in Role_Manager::gate_rest() reaches the
1669 + * same answer through Capability_Manager::capability_for_route() — both are
1670 + * kept so neither layer alone is load-bearing.
1671 + *
1672 + * @since 2.1.3
1673 + *
1674 + * @param WP_REST_Request $request Request.
1675 + * @return string
1676 + */
1677 + private function capability_for_request(WP_REST_Request $request): string {
1678 + // URL params only. get_param() searches the JSON body, the POST body
1679 + // and the query string ahead of the route path, so on the routes that
1680 + // declare no {category} — /global, /validate, /schema, /export,
1681 + // /backup, /restore — it read pure caller input and let a request
1682 + // nominate the capability it would be checked against (#582). Reading
1683 + // the path is also what Role_Manager::gate_rest() does, so the two
1684 + // layers now agree and the claim above is true again.
1685 + $category = $request->get_url_params()['category'] ?? null;
1686 +
1687 + if (!is_string($category) || '' === $category) {
1688 + return 'thinkrank_settings';
1689 + }
1690 +
1691 + return \ThinkRank\Core\Capability_Manager::capability_for_settings_category($category);
1692 + }
1693 +
1694 + /**
1453 1695 * Check permissions for managing settings
1454 1696 *
1455 1697 * @since 1.0.0
1456 1698 *
@@ -1455,9 +1697,27 @@
1455 1697 * @since 1.0.0
1456 1698 *
1457 1699 * @return bool Permission status
1458 1700 */
1459 - public function check_manage_permissions(): bool {
1701 + public function check_manage_permissions(WP_REST_Request $request): bool {
1702 + return \ThinkRank\Core\Capability_Manager::current_user_can(
1703 + $this->capability_for_request($request)
1704 + );
1705 + }
1706 +
1707 + /**
1708 + * Check permissions for administrator-only settings operations.
1709 + *
1710 + * The Role Manager can delegate `thinkrank_settings` to non-admin roles so
1711 + * they can manage the plugin's SEO configuration. Schema-level (DDL) and
1712 + * destructive whole-configuration operations — performance indexes, reset,
1713 + * import — are a different altitude and stay with site administrators.
1714 + *
1715 + * @since 1.29.0
1716 + *
1717 + * @return bool Permission status
1718 + */
1719 + public function check_admin_permissions(): bool {
1460 1720 return current_user_can('manage_options');
1461 1721 }
1462 1722
1463 1723 /**
@@ -1539,37 +1799,8 @@
1539 1799 update_option("thinkrank_settings_{$category}_last_updated", current_time('mysql'));
1540 1800 }
1541 1801
1542 1802 /**
1543 - * Analyze cross-category conflicts
1544 - *
1545 - * @since 1.0.0
1546 - *
1547 - * @param array $settings Settings data
1548 - * @return array Conflict analysis
1549 - */
1550 - private function analyze_cross_category_conflicts(array $settings): array {
1551 - $conflicts = [];
1552 -
1553 - // Example conflict detection logic
1554 - // This would be enhanced with actual conflict detection algorithms
1555 -
1556 - // Check for conflicting meta title settings
1557 - $title_conflicts = $this->detect_title_conflicts($settings);
1558 - if (!empty($title_conflicts)) {
1559 - $conflicts = array_merge($conflicts, $title_conflicts);
1560 - }
1561 -
1562 - // Check for conflicting schema settings
1563 - $schema_conflicts = $this->detect_schema_conflicts($settings);
1564 - if (!empty($schema_conflicts)) {
1565 - $conflicts = array_merge($conflicts, $schema_conflicts);
1566 - }
1567 -
1568 - return $conflicts;
1569 - }
1570 -
1571 - /**
1572 1803 * Format export data
1573 1804 *
1574 1805 * @since 1.0.0
1575 1806 *
@@ -1642,16 +1873,23 @@
1642 1873 'metadata' => $backup_metadata,
1643 1874 'settings' => $backup_data
1644 1875 ];
1645 1876
1646 - $saved = update_option("thinkrank_backup_{$backup_id}", $backup_record);
1877 + // Store as a NON-autoloaded option — each backup is a full multi-category
1878 + // snapshot and must not be loaded into memory on every front-end/admin
1879 + // request.
1880 + $saved = update_option("thinkrank_backup_{$backup_id}", $backup_record, false);
1647 1881
1648 1882 if ($saved) {
1649 - // Add to backup index
1883 + // Add to backup index (also non-autoloaded).
1650 1884 $backup_index = get_option('thinkrank_backup_index', []);
1651 1885 $backup_index[$backup_id] = $backup_metadata;
1652 - update_option('thinkrank_backup_index', $backup_index);
1653 1886
1887 + // Cap the retained set so the backups can't accumulate unbounded.
1888 + $backup_index = $this->prune_settings_backups($backup_index);
1889 +
1890 + update_option('thinkrank_backup_index', $backup_index, false);
1891 +
1654 1892 return $backup_id;
1655 1893 }
1656 1894
1657 1895 return false;
@@ -1657,8 +1895,37 @@
1657 1895 return false;
1658 1896 }
1659 1897
1660 1898 /**
1899 + * Keep only the most recent settings backups, deleting the option rows for
1900 + * any pruned from the index (oldest first).
1901 + *
1902 + * @param array $backup_index backup_id => metadata map.
1903 + * @return array Pruned index.
1904 + */
1905 + private function prune_settings_backups(array $backup_index): array {
1906 + $max_backups = 10;
1907 +
1908 + if (count($backup_index) <= $max_backups) {
1909 + return $backup_index;
1910 + }
1911 +
1912 + // Oldest first (missing timestamps sort earliest).
1913 + uasort($backup_index, static function ($a, $b) {
1914 + return strcmp((string) ($a['created_at'] ?? ''), (string) ($b['created_at'] ?? ''));
1915 + });
1916 +
1917 + // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found -- the loop shrinks $backup_index, so the count has to be re-read.
1918 + while (count($backup_index) > $max_backups) {
1919 + $oldest_id = array_key_first($backup_index);
1920 + unset($backup_index[$oldest_id]);
1921 + delete_option("thinkrank_backup_{$oldest_id}");
1922 + }
1923 +
1924 + return $backup_index;
1925 + }
1926 +
1927 + /**
1661 1928 * Load settings backup
1662 1929 *
1663 1930 * @since 1.0.0
1664 1931 *
@@ -1714,13 +1981,100 @@
1714 1981 'required' => false,
1715 1982 'type' => 'boolean',
1716 1983 'default' => true,
1717 1984 'description' => 'Whether to validate settings before updating'
1985 + ],
1986 + // Declared so the REST schema validates/normalises them. They were read
1987 + // by the handler while undeclared, which skipped validation entirely (#367).
1988 + 'context_type' => [
1989 + 'required' => false,
1990 + 'type' => 'string',
1991 + 'enum' => ['site', 'post', 'page', 'product'],
1992 + 'default' => 'site',
1993 + 'description' => 'Object context these settings apply to'
1994 + ],
1995 + 'context_id' => [
1996 + 'required' => false,
1997 + 'type' => 'integer',
1998 + 'minimum' => 1,
1999 + 'description' => 'Object ID when context_type is not "site"'
1718 2000 ]
1719 2001 ];
1720 2002 }
1721 2003
1722 2004 /**
2005 + * Authorise the object context a category settings write targets.
2006 + *
2007 + * The Settings section capability is delegatable, so a non-administrator can
2008 + * reach this controller. Writing settings for a specific post is an edit of
2009 + * that post and must be authorised as one — mirroring the per-object check the
2010 + * social-media write route performs (#277, #367).
2011 + *
2012 + * @since 1.32.0
2013 + *
2014 + * @param string $context_type Requested context type.
2015 + * @param int|null $context_id Requested object ID.
2016 + * @return true|WP_Error True when the write is allowed, WP_Error otherwise.
2017 + */
2018 + private function authorize_settings_context(string $context_type, ?int $context_id) {
2019 + if ('site' === $context_type) {
2020 + return true;
2021 + }
2022 +
2023 + if (!in_array($context_type, ['post', 'page', 'product'], true)) {
2024 + return new WP_Error(
2025 + 'invalid_context',
2026 + 'Invalid context type provided',
2027 + ['status' => 400]
2028 + );
2029 + }
2030 +
2031 + if (!$context_id || $context_id <= 0) {
2032 + return new WP_Error(
2033 + 'invalid_context',
2034 + 'A valid context_id is required for non-site contexts',
2035 + ['status' => 400]
2036 + );
2037 + }
2038 +
2039 + $post = get_post($context_id);
2040 +
2041 + if (!$post || 'revision' === $post->post_type) {
2042 + return new WP_Error(
2043 + 'invalid_context',
2044 + 'The requested content could not be found',
2045 + ['status' => 404]
2046 + );
2047 + }
2048 +
2049 + // The declared context must match the one the front-end read path derives
2050 + // from the real post type, otherwise `page`/`product` can alias an arbitrary
2051 + // object and the row is written where nothing will ever read it. Mirrors
2052 + // Seo_Manager::get_context_type() — custom post types fall back to 'post'.
2053 + $expected_context = in_array($post->post_type, ['post', 'page', 'product'], true)
2054 + ? $post->post_type
2055 + : 'post';
2056 +
2057 + if ($context_type !== $expected_context) {
2058 + return new WP_Error(
2059 + 'invalid_context',
2060 + 'The context type does not match the requested content.',
2061 + ['status' => 400]
2062 + );
2063 + }
2064 +
2065 + if (!current_user_can('edit_post', $context_id)) {
2066 + return new WP_Error(
2067 + 'rest_forbidden',
2068 + 'You are not allowed to edit settings for this content.',
2069 + ['status' => 403]
2070 + );
2071 + }
2072 +
2073 + return true;
2074 + }
2075 +
2076 + /**
1723 2077 * Get arguments for validation endpoint
1724 2078 *
1725 2079 * @since 1.0.0
1726 2080 *
@@ -1878,28 +2232,8 @@
1878 2232 ];
1879 2233 }
1880 2234
1881 2235 /**
1882 - * Get arguments for conflict resolution endpoint
1883 - *
1884 - * @since 1.0.0
1885 - *
1886 - * @return array Arguments array
1887 - */
1888 - private function get_conflict_resolution_args(): array {
1889 - return [
1890 - 'resolutions' => [
1891 - 'required' => true,
1892 - 'type' => 'array',
1893 - 'items' => [
1894 - 'type' => 'object'
1895 - ],
1896 - 'description' => 'Conflict resolutions to apply'
1897 - ]
1898 - ];
1899 - }
1900 -
1901 - /**
1902 2236 * Get arguments for reset endpoint
1903 2237 *
1904 2238 * @since 1.0.0
1905 2239 *
@@ -1925,79 +2259,78 @@
1925 2259 ];
1926 2260 }
1927 2261
1928 2262 /**
1929 - * Get arguments for bulk operations endpoint
2263 + * Snapshot the given categories' current settings into a persisted backup.
1930 2264 *
1931 - * @since 1.0.0
2265 + * Backs the pre-reset backup and restore-point features with real storage
2266 + * (via save_settings_backup) instead of a fabricated id, so operators have a
2267 + * genuine rollback snapshot before a destructive reset/restore.
1932 2268 *
1933 - * @return array Arguments array
2269 + * @param array $categories Categories to snapshot.
2270 + * @param string $label Human-readable label for the backup.
2271 + * @return string Backup id, or '' if the snapshot could not be persisted.
1934 2272 */
1935 - private function get_bulk_operations_args(): array {
1936 - return [
1937 - 'operation' => [
1938 - 'required' => true,
1939 - 'type' => 'string',
1940 - 'enum' => ['validate_settings', 'update_settings', 'export_settings', 'reset_settings'],
1941 - 'description' => 'Bulk operation type'
1942 - ],
1943 - 'items' => [
1944 - 'required' => true,
1945 - 'type' => 'array',
1946 - 'items' => [
1947 - 'type' => 'object'
1948 - ],
1949 - 'description' => 'Items to process in bulk'
1950 - ],
1951 - 'options' => [
1952 - 'required' => false,
1953 - 'type' => 'object',
1954 - 'description' => 'Bulk operation options'
1955 - ]
1956 - ];
1957 - }
2273 + private function create_settings_snapshot(array $categories, string $label): string {
2274 + $backup_data = [];
2275 + foreach ($categories as $category) {
2276 + if (isset($this->setting_categories[$category])) {
2277 + $backup_data[$category] = $this->read_category($category);
2278 + }
2279 + }
1958 2280
1959 - /**
1960 - * Placeholder implementations for methods referenced but not yet implemented
1961 - * These would be enhanced with actual conflict detection and resolution algorithms
1962 - */
2281 + // A snapshot that captured nothing for a category that does hold settings
2282 + // is worse than no snapshot: reset checks only that an id came back, so an
2283 + // empty one is accepted as a rollback point and the defaults go over live
2284 + // data that can no longer be recovered. That is exactly what #689 was.
2285 + //
2286 + // Ask the owning manager directly rather than trusting read_category(),
2287 + // so this stays a real check if a future edit sends a read back to the
2288 + // wrong store instead of quietly agreeing with it.
2289 + foreach ($backup_data as $category => $captured) {
2290 + if (!empty($captured) || !$this->has_seo_manager($category)) {
2291 + continue;
2292 + }
1963 2293
1964 - private function detect_title_conflicts(array $settings): array {
1965 - return []; // Would implement actual title conflict detection
1966 - }
2294 + if (!empty((array) $this->get_seo_manager($category)->get_settings('site', null))) {
2295 + return '';
2296 + }
2297 + }
1967 2298
1968 - private function detect_schema_conflicts(array $settings): array {
1969 - return []; // Would implement actual schema conflict detection
1970 - }
2299 + $backup_metadata = [
2300 + 'backup_name' => $label . ' ' . gmdate('Y-m-d_H-i-s'),
2301 + 'description' => $label,
2302 + 'created_at' => current_time('mysql'),
2303 + 'created_by' => get_current_user_id(),
2304 + 'categories' => $categories,
2305 + 'settings_version' => $this->get_settings_version(),
2306 + 'wordpress_version' => get_bloginfo('version'),
2307 + 'automatic' => true,
2308 + ];
1971 2309
1972 - private function generate_conflict_resolution_suggestions(array $conflicts): array {
1973 - return []; // Would implement actual resolution suggestions
1974 - }
2310 + $backup_id = $this->save_settings_backup($backup_data, $backup_metadata);
1975 2311
1976 - private function apply_conflict_resolution(string $conflict_id, string $action, array $data): array {
1977 - return ['success' => true, 'action' => $action]; // Would implement actual resolution
2312 + return $backup_id ?: '';
1978 2313 }
1979 2314
2315 + /**
2316 + * Create a full-snapshot restore point before restoring a backup.
2317 + *
2318 + * @return string Backup id, or '' if it could not be persisted.
2319 + */
1980 2320 private function create_restore_point(): string {
1981 - return uniqid('restore_point_', true); // Would implement actual restore point creation
2321 + return $this->create_settings_snapshot(
2322 + array_keys($this->setting_categories),
2323 + 'Automatic restore point'
2324 + );
1982 2325 }
1983 2326
2327 + /**
2328 + * Create a safety backup of the given categories before a reset.
2329 + *
2330 + * @param array $categories Categories about to be reset.
2331 + * @return string Backup id, or '' if it could not be persisted.
2332 + */
1984 2333 private function create_pre_reset_backup(array $categories): string {
1985 - return uniqid('pre_reset_backup_', true); // Would implement actual pre-reset backup
1986 - }
1987 -
1988 - private function validate_category_settings_bulk(array $item): array {
1989 - return ['validation' => 'passed']; // Would implement bulk validation
1990 - }
1991 -
1992 - private function update_category_settings_bulk(array $item): array {
1993 - return ['update' => 'successful']; // Would implement bulk update
1994 - }
1995 -
1996 - private function export_category_settings_bulk(array $item): array {
1997 - return ['export' => 'completed']; // Would implement bulk export
1998 - }
1999 -
2000 - private function reset_category_settings_bulk(array $item): array {
2001 - return ['reset' => 'completed']; // Would implement bulk reset
2334 + return $this->create_settings_snapshot($categories, 'Automatic pre-reset backup');
2002 2335 }
2003 2336 }