← 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 | ) |