PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/seo/class-abstract-seo-manager.php +537 -56 1.0.2 → 2.9.0 View file →
@@ -1,5 +1,6 @@
1 1 <?php
2 +
2 3 /**
3 4 * Abstract SEO Manager Base Class
4 5 *
5 6 * Provides common functionality for all SEO managers including database operations,
@@ -16,8 +17,13 @@
16 17 namespace ThinkRank\SEO;
17 18
18 19 use ThinkRank\SEO\Interfaces\SEO_Manager_Interface;
19 20
21 +// Prevent direct access
22 +if (!defined('ABSPATH')) {
23 + exit;
24 +}
25 +
20 26 /**
21 27 * Abstract SEO Manager Base Class
22 28 *
23 29 * Implements common functionality for all SEO managers following DRY principles.
@@ -51,8 +57,30 @@
51 57 */
52 58 protected string $manager_type;
53 59
54 60 /**
61 + * Why the most recent save_settings() call failed.
62 + *
63 + * save_settings() returns a bare bool, so the caller that has to tell the
64 + * user something ends up printing a generic "failed" string while the real
65 + * reason goes only to the error log. Holding it here lets the REST layer
66 + * put the actual cause in the response. First failure wins: a rejected
67 + * INSERT can cascade across keys, and the first one names the root cause.
68 + *
69 + * @since 1.32.1
70 + * @var string
71 + */
72 + protected string $last_save_error = '';
73 +
74 + /**
75 + * Machine-readable counterpart to $last_save_error.
76 + *
77 + * @since 1.32.1
78 + * @var string
79 + */
80 + protected string $last_save_error_code = '';
81 +
82 + /**
55 83 * Supported context types
56 84 *
57 85 * @since 1.0.0
58 86 * @var array
@@ -67,9 +95,9 @@
67 95 * @param string $manager_type The manager type identifier
68 96 */
69 97 public function __construct(string $manager_type) {
70 98 global $wpdb;
71 -
99 +
72 100 $this->wpdb = $wpdb;
73 101 $this->settings_table = $wpdb->prefix . 'thinkrank_seo_settings';
74 102 $this->manager_type = sanitize_key($manager_type);
75 103 }
@@ -76,8 +104,15 @@
76 104
77 105 /**
78 106 * Get SEO settings for a specific context
79 107 *
108 + * The returned array always carries every key the context defines: the
109 + * saved rows are merged OVER the context defaults, so callers can read a
110 + * key without checking whether it exists. That also means the result never
111 + * distinguishes "the user saved this" from "this is the built-in default" —
112 + * when a caller needs that distinction (an audit asking whether anything
113 + * was configured at all), use get_stored_settings() instead.
114 + *
80 115 * @since 1.0.0
81 116 *
82 117 * @param string $context_type The context type
83 118 * @param int|null $context_id Optional. Context ID
@@ -84,32 +119,92 @@
84 119 * @return array SEO settings array
85 120 */
86 121 public function get_settings(string $context_type, ?int $context_id = null): array {
87 122 $context_type = sanitize_key($context_type);
88 -
123 +
89 124 if (!in_array($context_type, $this->get_supported_contexts(), true)) {
90 125 return $this->get_default_settings($context_type);
91 126 }
92 127
128 + // Serve from the object cache when available. This runs on every front-end
129 + // request (the_content, thumbnails), so avoiding a DB hit per request matters.
130 + $cache_key = $this->get_cache_key($context_type, $context_id);
131 + $cached = wp_cache_get($cache_key, 'thinkrank_seo');
132 + if (is_array($cached)) {
133 + return $cached;
134 + }
135 +
136 + // Merge with defaults to ensure all required keys exist
137 + $merged = array_merge(
138 + $this->get_default_settings($context_type),
139 + $this->get_stored_settings($context_type, $context_id)
140 + );
141 +
142 + // Cache the resolved settings; invalidated on every save via clear_cache().
143 + wp_cache_set($cache_key, $merged, 'thinkrank_seo');
144 +
145 + return $merged;
146 + }
147 +
148 + /**
149 + * Get ONLY the settings actually saved for a context — no defaults merged.
150 + *
151 + * get_settings() layers the context defaults under the stored rows, which
152 + * makes "has the user configured anything?" unanswerable through it: a site
153 + * with zero saved rows still gets back a fully populated array. Any audit
154 + * or first-run check that must tell a configured site from an untouched one
155 + * has to read the storage layer directly, which is what this exposes.
156 + *
157 + * Returns an empty array when nothing has been saved for the context, or
158 + * when the context type is not supported by this manager.
159 + *
160 + * Note this is the storage layer, not the settings a manager reports: a
161 + * subclass that overrides get_settings() to layer in another source —
162 + * Schema_Management_System fills its logo and organization fields from Site
163 + * Identity, Social_Meta_Manager has its own override — contributes nothing
164 + * here. Read it to ask what the site saved, never to read a value out.
165 + *
166 + * @since 2.3.1
167 + *
168 + * @param string $context_type The context type
169 + * @param int|null $context_id Optional. Context ID
170 + * @return array Saved settings, keyed by setting key. Empty when nothing is stored.
171 + */
172 + public function get_stored_settings(string $context_type, ?int $context_id = null): array {
173 + $context_type = sanitize_key($context_type);
174 +
175 + if (!in_array($context_type, $this->get_supported_contexts(), true)) {
176 + return [];
177 + }
178 +
93 179 // Convert NULL context_id to 0 for site-wide settings to match save behavior
94 180 $db_context_id = $context_id === null ? 0 : $context_id;
95 181
96 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SEO settings require direct database access for real-time data, table name is validated
182 + // Cached under its own key so the merged and unmerged views can never be
183 + // served for one another. clear_cache() drops both on every save.
184 + $cache_key = $this->get_stored_cache_key($context_type, $context_id);
185 + $cached = wp_cache_get($cache_key, 'thinkrank_seo');
186 + if (is_array($cached)) {
187 + return $cached;
188 + }
189 +
190 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings require direct database access for real-time data, table name is validated
97 191 $sql = sprintf(
98 192 'SELECT setting_key, setting_value FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
99 193 $this->settings_table
100 194 );
101 195
196 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
102 197 $results = $this->wpdb->get_results(
103 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
198 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
104 199 $this->wpdb->prepare(
105 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
200 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
106 201 $sql,
107 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Parameters are validated and used as placeholders
202 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
108 203 $context_type,
109 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- context_id is validated integer
204 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- context_id is validated integer
110 205 $db_context_id,
111 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- manager_type is validated class property
206 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
112 207 $this->manager_type
113 208 ),
114 209 ARRAY_A
115 210 );
@@ -114,17 +209,13 @@
114 209 ARRAY_A
115 210 );
116 211
117 212 $settings = [];
118 - foreach ($results as $row) {
213 + foreach ((array) $results as $row) {
119 214 $value = maybe_unserialize($row['setting_value']);
120 215
121 - // Ensure proper data type conversion for common boolean fields
122 - if (in_array($row['setting_key'], [
123 - 'enabled', 'auto_generate_schema', 'rich_snippets_optimization',
124 - 'performance_tracking', 'auto_deploy', 'validation_on_save',
125 - 'rich_snippets_testing', 'organization_schema', 'knowledge_graph'
126 - ], true)) {
216 + // Ensure proper data type conversion for boolean fields
217 + if (in_array($row['setting_key'], $this->boolean_setting_keys(), true)) {
127 218 // Convert string/numeric boolean representations to actual booleans
128 219 if (is_string($value)) {
129 220 $value = in_array(strtolower($value), ['true', '1', 'yes', 'on'], true);
130 221 } elseif (is_numeric($value)) {
@@ -139,10 +230,11 @@
139 230
140 231 $settings[$row['setting_key']] = $value;
141 232 }
142 233
143 - // Merge with defaults to ensure all required keys exist
144 - return array_merge($this->get_default_settings($context_type), $settings);
234 + wp_cache_set($cache_key, $settings, 'thinkrank_seo');
235 +
236 + return $settings;
145 237 }
146 238
147 239 /**
148 240 * Save SEO settings for a specific context
@@ -156,16 +248,39 @@
156 248 */
157 249 public function save_settings(string $context_type, ?int $context_id, array $settings): bool {
158 250 $context_type = sanitize_key($context_type);
159 251
252 + $this->last_save_error = '';
253 + $this->last_save_error_code = '';
254 +
160 255 if (!in_array($context_type, $this->get_supported_contexts(), true)) {
161 - // Unsupported context type - validation failed
256 + $this->log_save_failure("unsupported context type '{$context_type}'", 'unsupported_context');
162 257 return false;
163 258 }
164 259
165 - // Check if settings table exists
260 + // Check if settings table exists. This is the failure a user cannot
261 + // diagnose from the UI: on hosts where CREATE TABLE failed (e.g. the
262 + // 767-byte InnoDB index limit on MySQL 5.6-era servers), every save in
263 + // every manager fails with a generic message while option-backed
264 + // features keep working — so name the cause loudly.
265 + //
266 + // Creation is retried on every request, so a table that stays missing
267 + // means the database is refusing the statement. Database_Schema records
268 + // that refusal; lead with it, because it is the only text here that
269 + // names this site's actual problem.
166 270 if (!$this->ensure_settings_table_exists()) {
167 - // Settings table creation failed
271 + $create_error = \ThinkRank\Database\Database_Schema::get_last_create_failure();
272 +
273 + $this->log_save_failure(
274 + "settings table '{$this->settings_table}' does not exist. " .
275 + ('' !== $create_error
276 + ? 'The database refused to create it: ' . $create_error
277 + : 'ThinkRank re-attempts creation on every load, so no reactivation is needed. ' .
278 + 'If the table never appears, the database is rejecting the CREATE TABLE: check that the ' .
279 + 'database user holds the CREATE privilege, and ask your host for the MySQL/MariaDB version, ' .
280 + 'as 5.6-era servers cap an index at 767 bytes and reject wider schemas.'),
281 + 'settings_table_missing'
282 + );
168 283 return false;
169 284 }
170 285
171 286 // Validate settings before saving
@@ -170,14 +285,17 @@
170 285
171 286 // Validate settings before saving
172 287 $validation = $this->validate_settings($settings);
173 288 if (!$validation['valid']) {
174 - // Settings validation failed - error details available in validation response
289 + $this->log_save_failure(
290 + 'validation failed: ' . wp_json_encode($validation['errors'] ?? []),
291 + 'validation_failed'
292 + );
175 293 return false;
176 294 }
177 295
178 296 // Sanitize settings
179 - $sanitized_settings = $this->sanitize_settings($settings);
297 + $sanitized_settings = $this->sanitize_settings($settings, $context_type);
180 298
181 299 $success = true;
182 300 foreach ($sanitized_settings as $key => $value) {
183 301 $sanitized_key = sanitize_key($key);
@@ -188,9 +306,11 @@
188 306 // MySQL treats multiple NULL values as distinct in UNIQUE constraints
189 307 $db_context_id = $context_id === null ? 0 : $context_id;
190 308
191 309 // Use INSERT ... ON DUPLICATE KEY UPDATE for proper upsert behavior
310 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $this->settings_table is a validated class property set from $wpdb->prefix.
192 311 $sql = $this->wpdb->prepare(
312 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
193 313 "INSERT INTO `{$this->settings_table}`
194 314 (`context_type`, `context_id`, `setting_category`, `setting_key`, `setting_value`, `is_active`, `created_at`, `updated_at`)
195 315 VALUES (%s, %d, %s, %s, %s, %d, %s, %s)
196 316 ON DUPLICATE KEY UPDATE
@@ -206,13 +326,17 @@
206 326 $current_time,
207 327 $current_time
208 328 );
209 329
210 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- SEO settings require direct database access, SQL is properly prepared
330 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings require direct database access, SQL is properly prepared
211 331 $result = $this->wpdb->query($sql);
212 332
213 333 if (false === $result) {
214 - // Database operation failed - error details available in wpdb->last_error
334 + $this->log_save_failure(
335 + "insert failed for key '{$sanitized_key}'" .
336 + ('' !== (string) $this->wpdb->last_error ? ' — ' . $this->wpdb->last_error : ''),
337 + 'db_insert_failed'
338 + );
215 339 $success = false;
216 340 }
217 341 }
218 342
@@ -218,8 +342,31 @@
218 342
219 343 // Clear relevant caches
220 344 $this->clear_cache($context_type, $context_id);
221 345
346 + /**
347 + * Fires after a settings category has been written.
348 + *
349 + * Lets one manager react to another's save — the Schema Manager uses it
350 + * to refresh LocalBusiness when Site Identity's Business Info changes,
351 + * since those fields live in a different category and never appear in a
352 + * schema settings payload (#455).
353 + *
354 + * @since 2.0.2
355 + *
356 + * @param string $manager_type Settings category that was saved.
357 + * @param array $settings The sanitized settings that were written.
358 + * @param string $context_type Context type.
359 + * @param int|null $context_id Context ID.
360 + */
361 + do_action(
362 + 'thinkrank_seo_settings_saved',
363 + $this->manager_type,
364 + $sanitized_settings,
365 + $context_type,
366 + $context_id
367 + );
368 +
222 369 return $success;
223 370 }
224 371
225 372 /**
@@ -236,9 +383,9 @@
236 383
237 384 // Convert NULL context_id to 0 for site-wide settings to match save behavior
238 385 $db_context_id = $context_id === null ? 0 : $context_id;
239 386
240 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SEO settings deletion requires direct database access
387 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings deletion requires direct database access
241 388 $result = $this->wpdb->delete(
242 389 $this->settings_table,
243 390 [
244 391 'context_type' => $context_type,
@@ -270,24 +417,25 @@
270 417
271 418 // Convert NULL context_id to 0 for site-wide settings to match save behavior
272 419 $db_context_id = $context_id === null ? 0 : $context_id;
273 420
274 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SEO settings existence check requires direct database access, table name is validated
421 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings existence check requires direct database access, table name is validated
275 422 $sql = sprintf(
276 423 'SELECT COUNT(*) FROM `%s` WHERE context_type = %%s AND context_id = %%d AND setting_category = %%s AND is_active = 1',
277 424 $this->settings_table
278 425 );
279 426
427 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
280 428 $count = $this->wpdb->get_var(
281 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
429 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
282 430 $this->wpdb->prepare(
283 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
431 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
284 432 $sql,
285 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Parameters are validated and used as placeholders
433 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Parameters are validated and used as placeholders
286 434 $context_type,
287 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- context_id is validated integer
435 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- context_id is validated integer
288 436 $db_context_id,
289 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- manager_type is validated class property
437 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
290 438 $this->manager_type
291 439 )
292 440 );
293 441
@@ -305,8 +453,153 @@
305 453 return $this->supported_contexts;
306 454 }
307 455
308 456 /**
457 + * String setting keys whose newlines must be preserved on save.
458 + *
459 + * @var string[]
460 + */
461 + private const MULTILINE_STRING_KEYS = ['robots_txt_content'];
462 +
463 + /**
464 + * Keys holding %token% TEMPLATES rather than plain text.
465 + *
466 + * sanitize_text_field() strips anything matching /%[a-f0-9]{2}/ as a
467 + * percent-encoded byte, which silently eats the leading characters of any
468 + * token whose first two letters are valid hex — %category_title% becomes
469 + * "tegory_title%", %date% becomes "te%". These keys therefore go through
470 + * sanitize_template_field() instead.
471 + */
472 + private const TEMPLATE_STRING_KEYS = [
473 + 'homepage_title', 'post_title', 'page_title', 'category_title', 'tag_title',
474 + 'search_title', 'archive_title', 'author_title',
475 + 'homepage_description', 'post_description', 'page_description',
476 + 'title_template', 'description_template',
477 + 'alt_format', 'title_format', 'caption_format',
478 + 'subject_template',
479 + ];
480 +
481 + /**
482 + * REST envelope keys that must never become stored settings.
483 + *
484 + * Every settings endpoint answers with
485 + * {settings, schema, context_type, context_id}. A caller that posts that
486 + * whole envelope back as `settings` writes those four keys as rows, and
487 + * because get_settings() returns every stored row, they then round-trip
488 + * into the next request forever — the Site Identity payload carried ~6KB
489 + * of a serialized copy of itself plus its own JSON schema on every save.
490 + * They are not settings in any manager, so drop them on the way in.
491 + *
492 + * @var string[]
493 + */
494 + protected const RESERVED_ENVELOPE_KEYS = ['settings', 'schema', 'context_type', 'context_id'];
495 +
496 + /**
497 + * Setting keys this manager stores that its defaults do not name.
498 + *
499 + * get_default_settings() is the natural allow-list, but it is not complete
500 + * in every manager: Site Identity declares 16 defaults while the screens
501 + * behind it legitimately store 55 keys, and gating on defaults alone would
502 + * stop title formats, breadcrumb configuration and business details from
503 + * saving at all. A manager whose defaults are complete overrides nothing.
504 + *
505 + * @since 2.0.1
506 + *
507 + * @return string[]
508 + */
509 + protected function additional_setting_keys(): array {
510 + return [];
511 + }
512 +
513 + /**
514 + * Regular expressions matching key FAMILIES this manager stores.
515 + *
516 + * For settings whose key set is open by design — the schema manager's
517 + * per-entity fields, the sitemap's per-post-type inclusion flags — an
518 + * enumerated list would go stale the first time a post type is registered.
519 + * Patterns are anchored and deliberately narrow: they must describe a
520 + * family the manager owns, never a catch-all.
521 + *
522 + * @since 2.0.1
523 + *
524 + * @return string[] PCRE patterns, delimiters included.
525 + */
526 + protected function dynamic_setting_key_patterns(): array {
527 + return [];
528 + }
529 +
530 + /**
531 + * The setting keys this manager accepts.
532 + *
533 + * @since 2.0.1
534 + *
535 + * @param string $context_type Context the save is for.
536 + * @return string[]
537 + */
538 + public function get_known_setting_keys(string $context_type = 'site'): array {
539 + $keys = array_merge(
540 + array_keys($this->get_default_settings($context_type)),
541 + $this->additional_setting_keys()
542 + );
543 +
544 + $keys = array_values(array_unique(array_filter($keys, 'is_string')));
545 +
546 + /**
547 + * Filters the keys a settings category accepts.
548 + *
549 + * Shared with Settings_Management_Endpoint so an add-on registering
550 + * settings against an existing category declares them once.
551 + *
552 + * @since 2.0.1
553 + *
554 + * @param string[] $keys Accepted setting keys.
555 + * @param string $category Settings category (the manager type).
556 + * @param string $context_type Context the save is for.
557 + */
558 + return apply_filters('thinkrank_known_setting_keys', $keys, $this->manager_type, $context_type);
559 + }
560 +
561 + /**
562 + * Whether this manager stores a setting under this key.
563 + *
564 + * Public counterpart of is_known_setting_key() for callers outside the
565 + * save path — the schema upgrade that clears rows written before the
566 + * allow-list existed, and tests.
567 + *
568 + * @since 2.0.1
569 + *
570 + * @param string $key Setting key.
571 + * @param string $context_type Context to judge it in.
572 + * @return bool
573 + */
574 + public function accepts_setting_key(string $key, string $context_type = 'site'): bool {
575 + return $this->is_known_setting_key(sanitize_key($key), $this->get_known_setting_keys($context_type));
576 + }
577 +
578 + /**
579 + * Whether a key is one this manager stores.
580 + *
581 + * @since 2.0.1
582 + *
583 + * @param string $key Sanitized setting key.
584 + * @param array $known Known keys for the context.
585 + * @return bool
586 + */
587 + protected function is_known_setting_key(string $key, array $known): bool {
588 + if (in_array($key, $known, true)) {
589 + return true;
590 + }
591 +
592 + foreach ($this->dynamic_setting_key_patterns() as $pattern) {
593 + if (preg_match($pattern, $key)) {
594 + return true;
595 + }
596 + }
597 +
598 + return false;
599 + }
600 +
601 + /**
309 602 * Sanitize settings array
310 603 *
311 604 * @since 1.0.0
312 605 *
@@ -312,16 +605,38 @@
312 605 *
313 606 * @param array $settings Settings to sanitize
314 607 * @return array Sanitized settings
315 608 */
316 - protected function sanitize_settings(array $settings): array {
609 + protected function sanitize_settings(array $settings, string $context_type = 'site'): array {
317 610 $sanitized = [];
611 + $known = $this->get_known_setting_keys($context_type);
318 612
319 613 foreach ($settings as $key => $value) {
320 614 $sanitized_key = sanitize_key($key);
321 615
616 + if (in_array($sanitized_key, self::RESERVED_ENVELOPE_KEYS, true)) {
617 + continue;
618 + }
619 +
620 + // A key no manager declares is not a setting. Stored, it becomes a
621 + // row that get_settings() returns forever, so it round-trips into
622 + // every later response and is re-posted by the UI on the next save
623 + // — which is how the REST envelope came to be stored (#452).
624 + if (!$this->is_known_setting_key($sanitized_key, $known)) {
625 + $this->log_unknown_setting_key($sanitized_key);
626 + continue;
627 + }
628 +
322 629 if (is_string($value)) {
323 - $sanitized[$sanitized_key] = sanitize_text_field($value);
630 + // Multi-line fields must keep their newlines; sanitize_text_field
631 + // would flatten them onto a single line.
632 + if (in_array($sanitized_key, self::MULTILINE_STRING_KEYS, true)) {
633 + $sanitized[$sanitized_key] = sanitize_textarea_field($value);
634 + } elseif (in_array($sanitized_key, self::TEMPLATE_STRING_KEYS, true)) {
635 + $sanitized[$sanitized_key] = $this->sanitize_template_field($value);
636 + } else {
637 + $sanitized[$sanitized_key] = sanitize_text_field($value);
638 + }
324 639 } elseif (is_array($value)) {
325 640 $sanitized[$sanitized_key] = $this->sanitize_array_recursive($value);
326 641 } elseif (is_numeric($value)) {
327 642 $sanitized[$sanitized_key] = (float) $value;
@@ -335,19 +650,73 @@
335 650 return $sanitized;
336 651 }
337 652
338 653 /**
654 + * Record a rejected setting key.
655 + *
656 + * Dropping silently is the hazard this gate carries: a legitimate key
657 + * missing from a manager's declarations would disappear with no trace. On
658 + * a debug install it says so; in production it stays quiet, since the
659 + * common source is a client posting fields that were never settings.
660 + *
661 + * @since 2.0.1
662 + *
663 + * @param string $key Key that was dropped.
664 + * @return void
665 + */
666 + private function log_unknown_setting_key(string $key): void {
667 + if (!defined('WP_DEBUG') || !WP_DEBUG) {
668 + return;
669 + }
670 +
671 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-only diagnostic; a dropped key is otherwise invisible.
672 + error_log(sprintf('ThinkRank [%s]: dropped unknown setting key "%s"', $this->manager_type, $key));
673 + }
674 +
675 + /**
676 + * Sanitize a %token% template while keeping its tokens intact.
677 + *
678 + * Applies the same protections as sanitize_text_field() — tag stripping,
679 + * invalid-UTF8 rejection, control-character and newline removal — but
680 + * deliberately omits its percent-encoding strip, which corrupts tokens like
681 + * %category_title% and %date%. Templates are only ever rendered into
682 + * escaped output, so no percent sequence here reaches a URL context raw.
683 + *
684 + * @since 1.20.1
685 + *
686 + * @param string $value Raw template
687 + * @return string Sanitized template
688 + */
689 + private function sanitize_template_field(string $value): string {
690 + $filtered = wp_check_invalid_utf8($value);
691 +
692 + if (strpos($filtered, '<') !== false) {
693 + $filtered = wp_pre_kses_less_than($filtered);
694 + // Wrap in a paragraph so wp_strip_all_tags() sees a complete node.
695 + $filtered = wp_strip_all_tags($filtered, false);
696 + $filtered = str_replace("<\n", "&lt;\n", $filtered);
697 + }
698 +
699 + // Collapse newlines/tabs to spaces and drop other control characters,
700 + // mirroring sanitize_text_field()'s single-line guarantee.
701 + $filtered = preg_replace('/[\r\n\t ]+/', ' ', $filtered);
702 + $filtered = preg_replace('/[\x00-\x1F\x7F]/u', '', (string) $filtered);
703 +
704 + return trim((string) $filtered);
705 + }
706 +
707 + /**
339 708 * Recursively sanitize array values
340 709 *
341 710 * @since 1.0.0
342 711 *
343 - * @param array $array Array to sanitize
712 + * @param array $input Array to sanitize
344 713 * @return array Sanitized array
345 714 */
346 - private function sanitize_array_recursive(array $array): array {
715 + private function sanitize_array_recursive(array $input): array {
347 716 $sanitized = [];
348 717
349 - foreach ($array as $key => $value) {
718 + foreach ($input as $key => $value) {
350 719 $sanitized_key = sanitize_key($key);
351 720
352 721 if (is_string($value)) {
353 722 $sanitized[$sanitized_key] = sanitize_text_field($value);
@@ -382,9 +751,13 @@
382 751 */
383 752 protected function clear_cache(string $context_type, ?int $context_id): void {
384 753 $cache_key = $this->get_cache_key($context_type, $context_id);
385 754 wp_cache_delete($cache_key, 'thinkrank_seo');
386 -
755 +
756 + // The defaults-free view is cached separately, so a save has to drop it
757 + // too or get_stored_settings() keeps answering with the pre-save rows.
758 + wp_cache_delete($this->get_stored_cache_key($context_type, $context_id), 'thinkrank_seo');
759 +
387 760 // Clear related transients
388 761 delete_transient("thinkrank_seo_{$this->manager_type}_{$context_type}_{$context_id}");
389 762 }
390 763
@@ -396,13 +769,68 @@
396 769 * @param string $context_type The context type
397 770 * @param int|null $context_id Optional. Context ID
398 771 * @return string Cache key
399 772 */
773 + /**
774 + * Setting keys stored as booleans, so a read hands them back as booleans.
775 + *
776 + * The database stores them as '1' / '', and a manager whose validator
777 + * demands a real boolean will then reject its own stored values — which is
778 + * exactly what made every save routed through
779 + * Seo_Settings_Manager::save_settings_by_category() fail after it merged
780 + * the existing settings back in (#395). Subclasses override this so the
781 + * read and the validator cannot drift apart.
782 + *
783 + * @since 2.0.1
784 + *
785 + * @return string[] Keys to coerce to boolean on read.
786 + */
787 + protected function boolean_setting_keys(): array {
788 + return [
789 + 'enabled',
790 + 'auto_generate_schema',
791 + 'rich_snippets_optimization',
792 + 'performance_tracking',
793 + 'auto_deploy',
794 + 'validation_on_save',
795 + 'rich_snippets_testing',
796 + 'organization_schema',
797 + 'knowledge_graph',
798 + 'add_missing_alt',
799 + 'add_missing_title',
800 + 'save_alt_to_media',
801 + 'auto_fill_on_upload',
802 + 'media_alt_overwrite',
803 + ];
804 + }
805 +
400 806 protected function get_cache_key(string $context_type, ?int $context_id): string {
401 - return "seo_settings_{$this->manager_type}_{$context_type}_" . ($context_id ?? 'site');
807 + // Normalise NULL to 0 so reads (which pass NULL for site-wide) and writes
808 + // (which pass 0) resolve to the SAME cache entry — otherwise a save would
809 + // never invalidate the value a front-end read cached.
810 + $db_context_id = $context_id === null ? 0 : $context_id;
811 + return "seo_settings_{$this->manager_type}_{$context_type}_{$db_context_id}";
402 812 }
403 813
404 814 /**
815 + * Cache key for the defaults-free view of a context.
816 + *
817 + * Deliberately distinct from get_cache_key(): the two views hold different
818 + * data (one merged with defaults, one only what was saved), so sharing an
819 + * entry would let whichever ran first answer for the other.
820 + *
821 + * @since 2.3.1
822 + *
823 + * @param string $context_type The context type
824 + * @param int|null $context_id Optional. Context ID
825 + * @return string
826 + */
827 + protected function get_stored_cache_key(string $context_type, ?int $context_id): string {
828 + $db_context_id = $context_id === null ? 0 : $context_id;
829 + return "seo_stored_settings_{$this->manager_type}_{$context_type}_{$db_context_id}";
830 + }
831 +
832 + /**
405 833 * Ensure settings table exists
406 834 *
407 835 * @since 1.0.0
408 836 *
@@ -407,16 +835,68 @@
407 835 * @since 1.0.0
408 836 *
409 837 * @return bool True if table exists or was created successfully
410 838 */
839 + /**
840 + * Record why a save failed, so "Failed to update … settings" in the UI has
841 + * a matching, actionable line in the PHP error log.
842 + *
843 + * A customer cannot act on the generic message, and neither can support
844 + * without this — the missing-table case (wizard blocked after migration,
845 + * every settings screen failing) looked identical to a validation problem.
846 + *
847 + * The reason is also kept on the instance so the REST layer can put it in
848 + * the response instead of a fixed string — see get_last_save_error().
849 + *
850 + * @since 1.28.0
851 + *
852 + * @param string $reason Why the save failed.
853 + * @param string $code Optional. Machine-readable failure code.
854 + * @return void
855 + */
856 + protected function log_save_failure(string $reason, string $code = 'save_failed'): void {
857 + if ('' === $this->last_save_error) {
858 + $this->last_save_error = $reason;
859 + $this->last_save_error_code = $code;
860 + }
861 +
862 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- deliberate diagnostic; the UI only shows a generic failure message.
863 + error_log(sprintf('ThinkRank [%s]: settings save failed — %s', $this->manager_type, $reason));
864 + }
865 +
866 + /**
867 + * Why the last save_settings() call returned false.
868 + *
869 + * @since 1.32.1
870 + *
871 + * @return string Failure reason, or '' if the last save succeeded.
872 + */
873 + public function get_last_save_error(): string {
874 + return $this->last_save_error;
875 + }
876 +
877 + /**
878 + * Machine-readable code for the last save failure.
879 + *
880 + * One of: unsupported_context, settings_table_missing, validation_failed,
881 + * db_insert_failed, save_failed.
882 + *
883 + * @since 1.32.1
884 + *
885 + * @return string Failure code, or '' if the last save succeeded.
886 + */
887 + public function get_last_save_error_code(): string {
888 + return $this->last_save_error_code;
889 + }
890 +
411 891 protected function ensure_settings_table_exists(): bool {
412 892 // Check if table exists
413 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Table existence check requires direct database access
893 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table existence check requires direct database access
414 894 $table_exists = $this->wpdb->get_var(
415 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
895 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
416 896 $this->wpdb->prepare(
417 897 "SHOW TABLES LIKE %s",
418 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- settings_table is validated class property
898 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- settings_table is validated class property
419 899 $this->settings_table
420 900 )
421 901 );
422 902
@@ -533,23 +1013,24 @@
533 1013 $settings = $this->get_settings($context_type, $context_id);
534 1014 $export_data['settings'][$context_type . ':' . ($context_id ?? 'site')] = $settings;
535 1015 } else {
536 1016 // Export all settings for this manager type
537 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- SEO settings export requires direct database access, table name is validated
538 - $sql = sprintf(
539 - 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1',
540 - $this->settings_table
541 - );
542 - $results = $this->wpdb->get_results(
543 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
544 - $this->wpdb->prepare(
545 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is properly prepared with placeholders
546 - $sql,
547 - // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- manager_type is validated class property
548 - $this->manager_type
549 - ),
550 - ARRAY_A
551 - );
1017 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO settings export requires direct database access, table name is validated
1018 + $sql = sprintf(
1019 + 'SELECT context_type, context_id, setting_key, setting_value FROM `%s` WHERE setting_category = %%s AND is_active = 1',
1020 + $this->settings_table
1021 + );
1022 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sql is built from sprintf with validated table name then prepared below.
1023 + $results = $this->wpdb->get_results(
1024 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1025 + $this->wpdb->prepare(
1026 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is properly prepared with placeholders
1027 + $sql,
1028 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- manager_type is validated class property
1029 + $this->manager_type
1030 + ),
1031 + ARRAY_A
1032 + );
552 1033
553 1034 $grouped_settings = [];
554 1035 foreach ($results as $row) {
555 1036 $key = $row['context_type'] . ':' . ($row['context_id'] ?? 'site');