| @@ -334,8 +334,20 @@ | ||
| 334 | 334 | * @var string |
| 335 | 335 | */ |
| 336 | 336 | public const ROBOTS_RESYNC_OPTION = 'thinkrank_robots_txt_resync_pending'; |
| 337 | 337 | |
| 338 | + /** | |
| 339 | + * Flag set when a plugin change may have altered the sitemap index. | |
| 340 | + * | |
| 341 | + * Separate from ROBOTS_RESYNC_OPTION because the two files exist | |
| 342 | + * independently: that flag is only set when a physical robots.txt exists, | |
| 343 | + * and the sitemap index needs rebuilding whether or not it does. | |
| 344 | + * | |
| 345 | + * @since 2.15.0 | |
| 346 | + * @var string | |
| 347 | + */ | |
| 348 | + public const SITEMAP_RESYNC_OPTION = 'thinkrank_sitemap_contributors_changed'; | |
| 349 | + | |
| 338 | 350 | public function __construct() { |
| 339 | 351 | parent::__construct('site_identity'); |
| 340 | 352 | |
| 341 | 353 | if (!self::$icon_sizes_listener_registered) { |
| @@ -357,8 +369,17 @@ | ||
| 357 | 369 | // HTML to anything that followed it. |
| 358 | 370 | add_action('activated_plugin', [self::class, 'flag_robots_txt_resync']); |
| 359 | 371 | add_action('deactivated_plugin', [self::class, 'flag_robots_txt_resync']); |
| 360 | 372 | add_action('init', [self::class, 'maybe_resync_robots_txt'], 99); |
| 373 | + | |
| 374 | + // The sitemap index is a second static file listing the same | |
| 375 | + // contributors, with its own rebuild path. #835 / #859 resynced | |
| 376 | + // robots.txt only, so after Pro was deactivated the index kept | |
| 377 | + // advertising news-sitemap.xml, which then served the home page | |
| 378 | + // as HTML (#920). | |
| 379 | + add_action('activated_plugin', [self::class, 'flag_sitemap_resync']); | |
| 380 | + add_action('deactivated_plugin', [self::class, 'flag_sitemap_resync']); | |
| 381 | + add_action('init', [self::class, 'maybe_resync_sitemap'], 99); | |
| 361 | 382 | } |
| 362 | 383 | } |
| 363 | 384 | |
| 364 | 385 | /** |
| @@ -408,8 +429,57 @@ | ||
| 408 | 429 | (new self())->sync_robots_txt_file(); |
| 409 | 430 | } |
| 410 | 431 | |
| 411 | 432 | /** |
| 433 | + * Note that the set of sitemap contributors may have changed. | |
| 434 | + * | |
| 435 | + * Unconditional, unlike flag_robots_txt_resync(): the sitemap files exist | |
| 436 | + * whether or not robots.txt does. The rebuild waits for the next request | |
| 437 | + * for the same reason as the robots.txt one, since `deactivated_plugin` | |
| 438 | + * still runs with the outgoing plugin's `thinkrank_additional_sitemaps` | |
| 439 | + * callback attached. | |
| 440 | + * | |
| 441 | + * @since 2.15.0 | |
| 442 | + * @return void | |
| 443 | + */ | |
| 444 | + public static function flag_sitemap_resync(): void { | |
| 445 | + update_option(self::SITEMAP_RESYNC_OPTION, 1, false); | |
| 446 | + } | |
| 447 | + | |
| 448 | + /** | |
| 449 | + * Queue a sitemap rebuild once, on the request after a contributor change. | |
| 450 | + * | |
| 451 | + * Goes through schedule_regeneration(), the debounced and lock-protected | |
| 452 | + * path a sitemap settings save uses, so a burst of plugin changes (a bulk | |
| 453 | + * deactivate, say) still produces one rebuild. That path also drops the | |
| 454 | + * cached dynamic documents, so sites serving the sitemap from PHP drop the | |
| 455 | + * entry as well. | |
| 456 | + * | |
| 457 | + * @since 2.15.0 | |
| 458 | + * @return void | |
| 459 | + */ | |
| 460 | + public static function maybe_resync_sitemap(): void { | |
| 461 | + if (!get_option(self::SITEMAP_RESYNC_OPTION)) { | |
| 462 | + return; | |
| 463 | + } | |
| 464 | + | |
| 465 | + // Cleared first, so a rebuild that fatals cannot be retried on every | |
| 466 | + // request for the rest of the site's life. | |
| 467 | + delete_option(self::SITEMAP_RESYNC_OPTION); | |
| 468 | + | |
| 469 | + $generator = new Sitemap_Generator(false); | |
| 470 | + $settings = $generator->get_settings('site'); | |
| 471 | + | |
| 472 | + // A disabled sitemap has no files to correct. Enabling it later builds | |
| 473 | + // from the contributors present at that time. | |
| 474 | + if (empty($settings['enabled'])) { | |
| 475 | + return; | |
| 476 | + } | |
| 477 | + | |
| 478 | + $generator->schedule_regeneration(); | |
| 479 | + } | |
| 480 | + | |
| 481 | + /** | |
| 412 | 482 | * Save settings, then refresh what a new canonical scheme invalidates. |
| 413 | 483 | * |
| 414 | 484 | * The static sitemap files are written with the scheme in force when they |
| 415 | 485 | * were built, and nothing else rebuilds them until a post or term changes. |
| @@ -1566,9 +1636,9 @@ | ||
| 1566 | 1636 | $optimization['suggestions'][] = 'Add a site logo for better branding and professional appearance'; |
| 1567 | 1637 | $optimization['score'] -= 20; |
| 1568 | 1638 | } else { |
| 1569 | 1639 | // Validate logo URL and dimensions |
| 1570 | - if (!filter_var($logo_url, FILTER_VALIDATE_URL)) { | |
| 1640 | + if (!\ThinkRank\Core\Url_Validator::is_http_url($logo_url)) { | |
| 1571 | 1641 | $optimization['warnings'][] = 'Logo URL format is invalid'; |
| 1572 | 1642 | $optimization['score'] -= 15; |
| 1573 | 1643 | } |
| 1574 | 1644 | } |
| @@ -1587,9 +1657,9 @@ | ||
| 1587 | 1657 | $optimization['score'] -= 10; |
| 1588 | 1658 | } |
| 1589 | 1659 | |
| 1590 | 1660 | // Additional logo analysis for local images |
| 1591 | - if (!empty($logo_url) && filter_var($logo_url, FILTER_VALIDATE_URL)) { | |
| 1661 | + if (!empty($logo_url) && \ThinkRank\Core\Url_Validator::is_http_url($logo_url)) { | |
| 1592 | 1662 | $attachment_id = Attachment_Lookup::id_from_url($logo_url); |
| 1593 | 1663 | if ($attachment_id) { |
| 1594 | 1664 | $image_meta = wp_get_attachment_metadata($attachment_id); |
| 1595 | 1665 | // The configured file's own size — a logo picked at a generated |
| @@ -2075,12 +2145,37 @@ | ||
| 2075 | 2145 | $validation['suggestions'][] = 'Consider making site description longer (120-160 characters)'; |
| 2076 | 2146 | } |
| 2077 | 2147 | } |
| 2078 | 2148 | |
| 2079 | - // Validate logo URL | |
| 2080 | - if (isset($settings['logo_url']) && !empty($settings['logo_url'])) { | |
| 2081 | - if (!filter_var($settings['logo_url'], FILTER_VALIDATE_URL)) { | |
| 2082 | - $validation['errors'][] = 'Logo URL must be a valid URL'; | |
| 2149 | + // Image and link URLs. These are written into src and href | |
| 2150 | + // attributes (the schema logo, the admin previews, the hero section), | |
| 2151 | + // so only web URLs are accepted. is_valid() takes any scheme, and | |
| 2152 | + // "javascript://%0Aalert(1)" passed it and was stored as | |
| 2153 | + // "javascript://alert(1)" once sanitize_text_field() dropped the %0A. | |
| 2154 | + // The logo and default social image must be absolute: they are | |
| 2155 | + // published in schema and Open Graph, which require it. The others | |
| 2156 | + // may also be a path on this site. | |
| 2157 | + $url_fields = [ | |
| 2158 | + 'logo_url' => ['Logo URL', false], | |
| 2159 | + 'default_social_image' => ['Default social image URL', false], | |
| 2160 | + 'favicon_url' => ['Favicon URL', true], | |
| 2161 | + 'apple_touch_icon_url' => ['Apple touch icon URL', true], | |
| 2162 | + 'hero_background_image' => ['Hero background image URL', true], | |
| 2163 | + 'hero_cta_url' => ['Call-to-action URL', true], | |
| 2164 | + ]; | |
| 2165 | + foreach ($url_fields as $key => [$label, $allow_path]) { | |
| 2166 | + if (!isset($settings[$key]) || '' === $settings[$key] || null === $settings[$key]) { | |
| 2167 | + continue; | |
| 2168 | + } | |
| 2169 | + | |
| 2170 | + $ok = $allow_path | |
| 2171 | + ? \ThinkRank\Core\Url_Validator::is_http_url_or_path($settings[$key]) | |
| 2172 | + : \ThinkRank\Core\Url_Validator::is_http_url($settings[$key]); | |
| 2173 | + | |
| 2174 | + if (!$ok) { | |
| 2175 | + $validation['errors'][] = $allow_path | |
| 2176 | + ? sprintf('%s must be an http or https URL, or a path starting with /', $label) | |
| 2177 | + : sprintf('%s must be an http or https URL', $label); | |
| 2083 | 2178 | $validation['valid'] = false; |
| 2084 | 2179 | } |
| 2085 | 2180 | } |
| 2086 | 2181 | |
| @@ -2518,9 +2613,9 @@ | ||
| 2518 | 2613 | } |
| 2519 | 2614 | |
| 2520 | 2615 | // CTA URL validation |
| 2521 | 2616 | if (!empty($settings['hero_cta_url'])) { |
| 2522 | - if (filter_var($settings['hero_cta_url'], FILTER_VALIDATE_URL) || strpos($settings['hero_cta_url'], '/') === 0) { | |
| 2617 | + if (\ThinkRank\Core\Url_Validator::is_http_url_or_path($settings['hero_cta_url'])) { | |
| 2523 | 2618 | $field_details[] = [ |
| 2524 | 2619 | 'field' => 'hero_cta_url', |
| 2525 | 2620 | 'label' => 'Call-to-action URL is properly configured.', |
| 2526 | 2621 | 'status' => 'valid', |
| @@ -2561,9 +2656,9 @@ | ||
| 2561 | 2656 | } |
| 2562 | 2657 | |
| 2563 | 2658 | // Site Logo validation (from Site Assets section) |
| 2564 | 2659 | if (!empty($settings['logo_url'])) { |
| 2565 | - if (filter_var($settings['logo_url'], FILTER_VALIDATE_URL)) { | |
| 2660 | + if (\ThinkRank\Core\Url_Validator::is_http_url($settings['logo_url'])) { | |
| 2566 | 2661 | $field_details[] = [ |
| 2567 | 2662 | 'field' => 'logo_url', |
| 2568 | 2663 | 'label' => 'Site logo is properly configured.', |
| 2569 | 2664 | 'status' => 'valid', |
| @@ -4032,9 +4127,12 @@ | ||
| 4032 | 4127 | $validation['warnings'][] = "Path '{$value}' should start with '/'"; |
| 4033 | 4128 | } |
| 4034 | 4129 | break; |
| 4035 | 4130 | case 'sitemap': |
| 4036 | - if (!filter_var($value, FILTER_VALIDATE_URL)) { | |
| 4131 | + // Url_Validator, not the raw PHP filter: on a site with an | |
| 4132 | + // internationalised domain the site's own sitemap URL is | |
| 4133 | + // non-ASCII and the raw filter refused it. | |
| 4134 | + if (!\ThinkRank\Core\Url_Validator::is_valid($value)) { | |
| 4037 | 4135 | $validation['errors'][] = "Invalid sitemap URL: {$value}"; |
| 4038 | 4136 | $validation['valid'] = false; |
| 4039 | 4137 | } |
| 4040 | 4138 | break; |
| @@ -4433,10 +4531,10 @@ | ||
| 4433 | 4531 | return $optimization; |
| 4434 | 4532 | } |
| 4435 | 4533 | |
| 4436 | 4534 | // Validate URL |
| 4437 | - if (!filter_var($value, FILTER_VALIDATE_URL)) { | |
| 4438 | - $optimization['validation']['errors'][] = "{$element} must be a valid URL"; | |
| 4535 | + if (!\ThinkRank\Core\Url_Validator::is_http_url($value)) { | |
| 4536 | + $optimization['validation']['errors'][] = "{$element} must be an http or https URL"; | |
| 4439 | 4537 | $optimization['validation']['valid'] = false; |
| 4440 | 4538 | return $optimization; |
| 4441 | 4539 | } |
| 4442 | 4540 | |