PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 All 55 releases
← All changes | includes/admin/importers/class-thinkrank-exporter.php +108 -14 2.2.0 → 2.14.0 View file →
@@ -66,8 +66,11 @@
66 66 'thinkrank_instant_indexing_settings',
67 67 'thinkrank_image_seo_settings',
68 68 'thinkrank_email_report_settings',
69 69 'thinkrank_author_archives_settings',
70 + // Thin content thresholds (#565). Saved by Thin_Content, not Settings,
71 + // so without this an Export/Restore round trip dropped them.
72 + 'thinkrank_thin_content_settings',
70 73 ];
71 74
72 75 /**
73 76 * Data types the free plugin exports itself.
@@ -75,10 +78,11 @@
75 78 private const CORE_TYPES = ['postmeta', 'termmeta', 'usermeta', 'settings'];
76 79
77 80 /**
78 81 * Secrets that are NOT in Settings::$encrypted_keys but must never reach an
79 - * export file either: the Brand Visibility per-platform API keys, which are
80 - * stored as plain settings today.
82 + * export file either: the per-platform API keys of the removed Brand
83 + * Visibility feature. They were stored as plain options, and a site that
84 + * used the feature still has them, so the exclusion outlives the feature.
81 85 *
82 86 * The encrypted keys themselves come from Settings::get_encrypted_keys() so
83 87 * this list cannot drift from that one.
84 88 */
@@ -89,8 +93,32 @@
89 93 'bv_key_perplexity',
90 94 ];
91 95
92 96 /**
97 + * Credential-shaped keys nested INSIDE an aggregate option.
98 + *
99 + * The flat secret list matches on key name wherever it appears, which is
100 + * the right rule for names that are unambiguous on their own
101 + * (`openai_api_key`). A bare `api_key` is not: it is only a secret because
102 + * of the option it sits in, so it is scoped here rather than banned
103 + * everywhere.
104 + *
105 + * IndexNow's key is public by design — the plugin serves it at
106 + * `/<key>.txt` — so this is hygiene rather than a leak being closed. It is
107 + * still stripped, because it is the only credential-shaped value that
108 + * currently reaches an export and leaving one exception in place is what
109 + * let redaction drift out of two of the three buckets to begin with.
110 + *
111 + * Snapshot_Migrator reads this to put the LOCAL value back on restore, so
112 + * stripping a key here never wipes the one the receiving site already has.
113 + *
114 + * @var array<string, string[]> Option name => secret keys inside it.
115 + */
116 + public const SECRET_OPTION_KEYS = [
117 + 'thinkrank_instant_indexing_settings' => ['api_key'],
118 + ];
119 +
120 + /**
93 121 * Constructor
94 122 */
95 123 public function __construct() {
96 124 $this->plugin_slug = self::SLUG;
@@ -113,10 +141,10 @@
113 141
114 142 /**
115 143 * Every type a ThinkRank export can carry.
116 144 *
117 - * Pro's data lives in its own tables (redirections, 404 logs, rank tracker,
118 - * Brand Visibility runs), which the free plugin cannot read. Rather than
145 + * Pro's data lives in its own tables (redirections, 404 logs, rank tracker),
146 + * which the free plugin cannot read. Rather than
119 147 * leaving Pro users with a half-export, Pro registers its types here and
120 148 * supplies the records through `thinkrank_export_records`; the restore side
121 149 * hands them back through `thinkrank_restore_records`. Free ships the seam
122 150 * so Pro is not blocked on a follow-up release.
@@ -312,18 +340,82 @@
312 340 return [
313 341 [
314 342 'type' => 'settings',
315 343 'source_plugin' => self::SLUG,
316 - 'data' => [
344 + 'data' => $this->redact_secrets([
317 345 'options' => $this->export_option_settings(),
318 346 'seo_table' => $this->export_seo_table_settings(),
319 347 'aggregate' => $this->export_aggregate_options(),
320 - ],
348 + ]),
321 349 ],
322 350 ];
323 351 }
324 352
325 353 /**
354 + * Strip every secret from the assembled payload, once.
355 + *
356 + * Redaction used to live inside export_option_settings() alone, so it
357 + * protected the bucket it was written for and neither of the other two:
358 + * the settings table and the aggregate options were serialized verbatim.
359 + * Nothing said they shouldn't be, which meant the day any of them held a
360 + * real credential it would leave the site silently.
361 + *
362 + * Doing it here instead of per-bucket means a bucket added later is
363 + * covered by construction rather than by remembering.
364 + *
365 + * @param array $data The three settings buckets.
366 + * @return array The same buckets with secrets removed.
367 + */
368 + private function redact_secrets(array $data): array {
369 + $secret_keys = self::secret_setting_keys();
370 +
371 + foreach (['options', 'seo_table', 'aggregate'] as $bucket) {
372 + if (isset($data[$bucket]) && is_array($data[$bucket])) {
373 + $data[$bucket] = $this->strip_secret_keys($data[$bucket], $secret_keys);
374 + }
375 + }
376 +
377 + // Option-scoped secrets: only a secret because of where they sit.
378 + foreach (self::SECRET_OPTION_KEYS as $option_name => $option_secrets) {
379 + if (!isset($data['aggregate'][$option_name]) || !is_array($data['aggregate'][$option_name])) {
380 + continue;
381 + }
382 +
383 + foreach ($option_secrets as $secret_key) {
384 + unset($data['aggregate'][$option_name][$secret_key]);
385 + }
386 + }
387 +
388 + return $data;
389 + }
390 +
391 + /**
392 + * Remove any key named as a secret, at any depth.
393 + *
394 + * Recursive on purpose: the settings table nests the stored key three
395 + * levels down (category → context → id → key), and a stored value can
396 + * itself be an array. A secret is a secret wherever it turns up.
397 + *
398 + * @param array $data Data to filter.
399 + * @param string[] $secret_keys Key names that must never be exported.
400 + * @return array
401 + */
402 + private function strip_secret_keys(array $data, array $secret_keys): array {
403 + foreach ($data as $key => $value) {
404 + if (is_string($key) && in_array($key, $secret_keys, true)) {
405 + unset($data[$key]);
406 + continue;
407 + }
408 +
409 + if (is_array($value)) {
410 + $data[$key] = $this->strip_secret_keys($value, $secret_keys);
411 + }
412 + }
413 +
414 + return $data;
415 + }
416 +
417 + /**
326 418 * Redirections live in Pro's own store, so the free plugin has nothing of
327 419 * its own here — the records come from Pro through the extension filter.
328 420 *
329 421 * @param int $page Page number (1-indexed)
@@ -458,26 +550,28 @@
458 550 * plain-text credential leak in a file users pass around, encrypted values
459 551 * carry the `trenc:v1:` marker and are derived from the site's auth salts —
460 552 * they could not be decrypted after a restore onto another site anyway.
461 553 *
554 + * The stripping itself is not done here: redact_secrets() runs over all
555 + * three buckets once, so this returns the raw store.
556 + *
462 557 * @return array Setting key => value
463 558 */
464 559 private function export_option_settings(): array {
465 - $settings = Settings::instance()->get_all();
466 -
467 - foreach ($this->get_secret_setting_keys() as $secret_key) {
468 - unset($settings[$secret_key]);
469 - }
470 -
471 - return $settings;
560 + return Settings::instance()->get_all();
472 561 }
473 562
474 563 /**
475 564 * Settings keys that must never appear in an export.
476 565 *
566 + * Public and static because Snapshot_Migrator needs the same list to undo
567 + * the redaction on the way back in: an aggregate option is restored whole,
568 + * so every key stripped here has to be carried forward from the receiving
569 + * site or the restore deletes it.
570 + *
477 571 * @return string[]
478 572 */
479 - private function get_secret_setting_keys(): array {
573 + public static function secret_setting_keys(): array {
480 574 return array_values(
481 575 array_unique(
482 576 array_merge(Settings::instance()->get_encrypted_keys(), self::EXTRA_SECRET_KEYS)
483 577 )