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