← All changes
|
includes/api/class-settings-management-endpoint.php
+739
-407
1.0.2
→
2.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 | } |