PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/api/class-settings-management-endpoint.php +739 -407 1.0.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,38 +842,127 @@
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
934 + // Clear analytics cache when GSC/GA settings change so fresh data is fetched
935 + if ($category === 'seo_analytics') {
936 + foreach (['7d', '30d', '90d'] as $range) {
937 + delete_transient("analytics_dashboard_v5_{$range}");
938 + delete_transient("seo_opportunities_{$range}");
939 + delete_transient("seo_insights_{$range}");
940 + }
941 + delete_transient('indexing_status');
942 + }
943 +
615 944 // Update category metadata
616 945 $this->update_category_metadata($category);
617 946
618 - // Get updated settings
619 - $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);
620 959
621 960 return new WP_REST_Response([
622 961 'success' => true,
623 962 'data' => [
624 963 'category' => $category,
625 - 'updated_settings' => $updated_settings,
964 + 'updated_settings' => $this->redact_category_settings($category, $updated_settings),
626 965 'validation_result' => $validation_result,
627 966 'settings_count' => count($updated_settings)
628 967 ],
629 968 'message' => "Settings for category '{$category}' updated successfully"
@@ -648,8 +987,11 @@
648 987 */
649 988 public function validate_settings(WP_REST_Request $request): WP_REST_Response {
650 989 try {
651 990 $settings = $request->get_param('settings');
991 + if (!is_array($settings)) {
992 + $settings = [];
993 + }
652 994 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
653 995
654 996 $validation_results = [];
655 997 $overall_valid = true;
@@ -659,11 +1001,21 @@
659 1001 continue;
660 1002 }
661 1003
662 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 + }
663 1015
664 - if (isset($this->seo_managers[$category])) {
665 - $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);
666 1018 $validation_results[$category] = $validation;
667 1019
668 1020 if (!$validation['valid']) {
669 1021 $overall_valid = false;
@@ -678,17 +1030,13 @@
678 1030 ];
679 1031 }
680 1032 }
681 1033
682 - // Check for cross-category conflicts
683 - $conflict_analysis = $this->analyze_cross_category_conflicts($settings);
684 -
685 1034 return new WP_REST_Response([
686 1035 'success' => true,
687 1036 'data' => [
688 1037 'validation_results' => $validation_results,
689 1038 'overall_valid' => $overall_valid,
690 - 'conflict_analysis' => $conflict_analysis,
691 1039 'validated_categories' => count($validation_results),
692 1040 'validation_timestamp' => current_time('mysql')
693 1041 ],
694 1042 'message' => 'Settings validation completed'
@@ -720,12 +1068,12 @@
720 1068 if (!isset($this->setting_categories[$category])) {
721 1069 continue;
722 1070 }
723 1071
724 - if (isset($this->seo_managers[$category])) {
1072 + if ($this->has_seo_manager($category)) {
725 1073 $schema_data[$category] = [
726 - 'schema' => $this->seo_managers[$category]->get_settings_schema($category),
727 - '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),
728 1076 'category_name' => $this->setting_categories[$category]
729 1077 ];
730 1078 } else {
731 1079 $schema_data[$category] = [
@@ -785,11 +1133,15 @@
785 1133 if (!isset($this->setting_categories[$category])) {
786 1134 continue;
787 1135 }
788 1136
789 - $export_data[$category] = $this->settings_manager->get_settings($category);
1137 + $export_data[$category] = $this->read_category($category);
790 1138 }
791 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 +
792 1144 // Add metadata if requested
793 1145 $metadata = [];
794 1146 if ($include_metadata) {
795 1147 $metadata = [
@@ -795,9 +1147,9 @@
795 1147 $metadata = [
796 1148 'export_timestamp' => current_time('mysql'),
797 1149 'export_version' => $this->get_settings_version(),
798 1150 'wordpress_version' => get_bloginfo('version'),
799 - 'thinkrank_version' => '1.0.0',
1151 + 'thinkrank_version' => defined('THINKRANK_VERSION') ? THINKRANK_VERSION : '',
800 1152 'site_url' => home_url(),
801 1153 'exported_categories' => $categories
802 1154 ];
803 1155 }
@@ -859,8 +1211,28 @@
859 1211 ['status' => 400]
860 1212 );
861 1213 }
862 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 +
863 1235 $import_results = [];
864 1236 $validation_results = [];
865 1237
866 1238 // Validate imported settings if requested
@@ -869,10 +1241,10 @@
869 1241 if (!isset($this->setting_categories[$category])) {
870 1242 continue;
871 1243 }
872 1244
873 - if (isset($this->seo_managers[$category])) {
874 - $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);
875 1247 $validation_results[$category] = $validation;
876 1248
877 1249 if (!$validation['valid']) {
878 1250 return new WP_Error(
@@ -899,9 +1271,9 @@
899 1271 }
900 1272
901 1273 try {
902 1274 // Check if settings exist and handle overwrite
903 - $existing_settings = $this->settings_manager->get_settings($category);
1275 + $existing_settings = $this->read_category($category);
904 1276
905 1277 if (!empty($existing_settings) && !$overwrite_existing) {
906 1278 $import_results[$category] = [
907 1279 'success' => false,
@@ -913,10 +1285,10 @@
913 1285 // Import settings
914 1286 $import_success = $this->settings_manager->update_settings($category_settings, $category);
915 1287
916 1288 // 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);
1289 + if ($this->has_seo_manager($category)) {
1290 + $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
919 1291 $import_success = $import_success && $manager_update;
920 1292 }
921 1293
922 1294 $import_results[$category] = [
@@ -974,9 +1346,9 @@
974 1346 // Create backup data
975 1347 $backup_data = [];
976 1348 foreach ($categories as $category) {
977 1349 if (isset($this->setting_categories[$category])) {
978 - $backup_data[$category] = $this->settings_manager->get_settings($category);
1350 + $backup_data[$category] = $this->read_category($category);
979 1351 }
980 1352 }
981 1353
982 1354 // Create backup metadata
@@ -1054,12 +1426,20 @@
1054 1426 ['status' => 404]
1055 1427 );
1056 1428 }
1057 1429
1058 - // 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.
1059 1432 $restore_point_id = null;
1060 1433 if ($create_restore_point) {
1061 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 + }
1062 1442 }
1063 1443
1064 1444 $restore_results = [];
1065 1445
@@ -1082,10 +1462,10 @@
1082 1462 // Restore settings
1083 1463 $restore_success = $this->settings_manager->update_settings($category_settings, $category);
1084 1464
1085 1465 // 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);
1466 + if ($this->has_seo_manager($category)) {
1467 + $manager_update = $this->get_seo_manager($category)->save_settings('site', null, $category_settings);
1088 1468 $restore_success = $restore_success && $manager_update;
1089 1469 }
1090 1470
1091 1471 $restore_results[$category] = [
@@ -1125,112 +1505,8 @@
1125 1505 }
1126 1506 }
1127 1507
1128 1508 /**
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 1509 * Reset settings to defaults
1234 1510 *
1235 1511 * @since 1.0.0
1236 1512 *
@@ -1241,12 +1517,21 @@
1241 1517 try {
1242 1518 $categories = $request->get_param('categories') ?? array_keys($this->setting_categories);
1243 1519 $create_backup = $request->get_param('create_backup') ?? true;
1244 1520
1245 - // 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.
1246 1524 $backup_id = null;
1247 1525 if ($create_backup) {
1248 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 + }
1249 1534 }
1250 1535
1251 1536 $reset_results = [];
1252 1537
@@ -1257,10 +1542,10 @@
1257 1542
1258 1543 try {
1259 1544 // Get default settings
1260 1545 $default_settings = [];
1261 - if (isset($this->seo_managers[$category])) {
1262 - $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);
1263 1548 }
1264 1549
1265 1550 // Reset to defaults
1266 1551 $reset_success = $this->settings_manager->update_settings($default_settings, $category);
@@ -1265,10 +1550,10 @@
1265 1550 // Reset to defaults
1266 1551 $reset_success = $this->settings_manager->update_settings($default_settings, $category);
1267 1552
1268 1553 // 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);
1554 + if ($this->has_seo_manager($category)) {
1555 + $manager_reset = $this->get_seo_manager($category)->save_settings('site', null, $default_settings);
1271 1556 $reset_success = $reset_success && $manager_reset;
1272 1557 }
1273 1558
1274 1559 $reset_results[$category] = [
@@ -1307,88 +1592,8 @@
1307 1592 }
1308 1593 }
1309 1594
1310 1595 /**
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 1596 * Add performance indexes to database tables
1392 1597 *
1393 1598 * @since 1.0.0
1394 1599 *
@@ -1394,9 +1599,9 @@
1394 1599 *
1395 1600 * @param WP_REST_Request $request Request object
1396 1601 * @return WP_REST_Response|WP_Error Response object
1397 1602 */
1398 - public function add_performance_indexes(WP_REST_Request $request): WP_REST_Response|WP_Error {
1603 + public function add_performance_indexes(WP_REST_Request $request) {
1399 1604 try {
1400 1605 // Import the Database_Schema class
1401 1606 if (!class_exists('ThinkRank\\Database\\Database_Schema')) {
1402 1607 require_once THINKRANK_PLUGIN_DIR . 'includes/database/class-database-schema.php';
@@ -1444,13 +1649,49 @@
1444 1649 * @since 1.0.0
1445 1650 *
1446 1651 * @return bool Permission status
1447 1652 */
1448 - public function check_read_permissions(): bool {
1449 - 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 + );
1450 1660 }
1451 1661
1452 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 + /**
1453 1694 * Check permissions for managing settings
1454 1695 *
1455 1696 * @since 1.0.0
1456 1697 *
@@ -1455,9 +1696,27 @@
1455 1696 * @since 1.0.0
1456 1697 *
1457 1698 * @return bool Permission status
1458 1699 */
1459 - 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 {
1460 1719 return current_user_can('manage_options');
1461 1720 }
1462 1721
1463 1722 /**
@@ -1539,37 +1798,8 @@
1539 1798 update_option("thinkrank_settings_{$category}_last_updated", current_time('mysql'));
1540 1799 }
1541 1800
1542 1801 /**
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 1802 * Format export data
1573 1803 *
1574 1804 * @since 1.0.0
1575 1805 *
@@ -1642,16 +1872,23 @@
1642 1872 'metadata' => $backup_metadata,
1643 1873 'settings' => $backup_data
1644 1874 ];
1645 1875
1646 - $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);
1647 1880
1648 1881 if ($saved) {
1649 - // Add to backup index
1882 + // Add to backup index (also non-autoloaded).
1650 1883 $backup_index = get_option('thinkrank_backup_index', []);
1651 1884 $backup_index[$backup_id] = $backup_metadata;
1652 - update_option('thinkrank_backup_index', $backup_index);
1653 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 +
1654 1891 return $backup_id;
1655 1892 }
1656 1893
1657 1894 return false;
@@ -1657,8 +1894,37 @@
1657 1894 return false;
1658 1895 }
1659 1896
1660 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 + /**
1661 1927 * Load settings backup
1662 1928 *
1663 1929 * @since 1.0.0
1664 1930 *
@@ -1714,13 +1980,100 @@
1714 1980 'required' => false,
1715 1981 'type' => 'boolean',
1716 1982 'default' => true,
1717 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"'
1718 1999 ]
1719 2000 ];
1720 2001 }
1721 2002
1722 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 + /**
1723 2076 * Get arguments for validation endpoint
1724 2077 *
1725 2078 * @since 1.0.0
1726 2079 *
@@ -1878,28 +2231,8 @@
1878 2231 ];
1879 2232 }
1880 2233
1881 2234 /**
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 2235 * Get arguments for reset endpoint
1903 2236 *
1904 2237 * @since 1.0.0
1905 2238 *
@@ -1925,79 +2258,78 @@
1925 2258 ];
1926 2259 }
1927 2260
1928 2261 /**
1929 - * Get arguments for bulk operations endpoint
2262 + * Snapshot the given categories' current settings into a persisted backup.
1930 2263 *
1931 - * @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.
1932 2267 *
1933 - * @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.
1934 2271 */
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 - }
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 + }
1958 2279
1959 - /**
1960 - * Placeholder implementations for methods referenced but not yet implemented
1961 - * These would be enhanced with actual conflict detection and resolution algorithms
1962 - */
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 + }
1963 2292
1964 - private function detect_title_conflicts(array $settings): array {
1965 - return []; // Would implement actual title conflict detection
1966 - }
2293 + if (!empty((array) $this->get_seo_manager($category)->get_settings('site', null))) {
2294 + return '';
2295 + }
2296 + }
1967 2297
1968 - private function detect_schema_conflicts(array $settings): array {
1969 - return []; // Would implement actual schema conflict detection
1970 - }
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 + ];
1971 2308
1972 - private function generate_conflict_resolution_suggestions(array $conflicts): array {
1973 - return []; // Would implement actual resolution suggestions
1974 - }
2309 + $backup_id = $this->save_settings_backup($backup_data, $backup_metadata);
1975 2310
1976 - private function apply_conflict_resolution(string $conflict_id, string $action, array $data): array {
1977 - return ['success' => true, 'action' => $action]; // Would implement actual resolution
2311 + return $backup_id ?: '';
1978 2312 }
1979 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 + */
1980 2319 private function create_restore_point(): string {
1981 - 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 + );
1982 2324 }
1983 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 + */
1984 2332 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
2333 + return $this->create_settings_snapshot($categories, 'Automatic pre-reset backup');
2002 2334 }
2003 2335 }