PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.1
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.1
6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 5.2 5.2.1 All 155 releases
mainwp / class / class-mainwp-site-url-corrector.php

class-mainwp-site-url-corrector.php in MainWP Dashboard: Self-hosted WordPress Management for Agencies 6.2.1, at class/class-mainwp-site-url-corrector.php

758 lines 28.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MainWP Site URL Corrector.
4 *
5 * Detects and corrects stored child site URLs whose scheme (http/https) or
6 * www prefix does not match the address the child site reports for itself
7 * ( get_option( 'siteurl' ) delivered on every sync and stored in the
8 * mainwp_wp.siteurl column ). A wrong scheme or www variant breaks the
9 * "Jump to WP Admin" login flow while sync keeps working, so users get no
10 * signal about the cause. See MWP-1662.
11 *
12 * Containment invariant: this class may only ever move a URL within
13 * {http,https} x {www.,''} of the already-registered host. It never crosses
14 * domains, ports or paths, no matter what the child reports.
15 *
16 * The corrector operates silently: no user notices are created. The audit
17 * trail is the per-site url_correct_history website option, a MainWP_Logger
18 * line and the public mainwp_site_url_corrected action.
19 *
20 * @package MainWP/Dashboard
21 */
22
23 namespace MainWP\Dashboard;
24
25 // Exit if accessed directly.
26 if ( ! defined( 'ABSPATH' ) ) {
27 exit;
28 }
29
30 /**
31 * Class MainWP_Site_Url_Corrector
32 *
33 * @package MainWP\Dashboard
34 */
35 class MainWP_Site_Url_Corrector { // phpcs:ignore Generic.Classes.OpeningBraceSameLine.ContentAfterBrace -- NOSONAR.
36
37 /**
38 * Stored and reported URLs match on scheme + host + port (paths are ignored).
39 */
40 const CATEGORY_IDENTICAL = 'identical';
41
42 /**
43 * Same scheme, hosts differ only by a leading www prefix.
44 */
45 const CATEGORY_WWW_ONLY = 'www_only';
46
47 /**
48 * Same host, stored http while the child reports https.
49 */
50 const CATEGORY_SCHEME_UPGRADE = 'scheme_upgrade';
51
52 /**
53 * Same host, stored https while the child reports http.
54 */
55 const CATEGORY_SCHEME_DOWNGRADE = 'scheme_downgrade';
56
57 /**
58 * Both a www difference and an http -> https upgrade.
59 */
60 const CATEGORY_SCHEME_WWW_UPGRADE = 'scheme_www_upgrade';
61
62 /**
63 * Both a www difference and an https -> http downgrade.
64 */
65 const CATEGORY_SCHEME_WWW_DOWNGRADE = 'scheme_www_downgrade';
66
67 /**
68 * Anything else: different domain, different port, unparseable input.
69 */
70 const CATEGORY_OTHER = 'other';
71
72 /**
73 * Website option: user lock. When set, no layer touches or reroutes the URL.
74 */
75 const OPTION_LOCK = 'url_correct_lock';
76
77 /**
78 * Website option: queued correction awaiting verify + apply (JSON).
79 */
80 const OPTION_PENDING = 'url_correct_pending';
81
82 /**
83 * Website option: last recorded suggestion / skip reason (JSON).
84 */
85 const OPTION_SUGGEST = 'url_correct_suggest';
86
87 /**
88 * Website option: applied corrections history (JSON array).
89 */
90 const OPTION_HISTORY = 'url_correct_history';
91
92 /**
93 * Global option enabling automatic apply of safe categories.
94 */
95 const OPTION_AUTO_ENABLED = 'mainwp_auto_correct_site_url';
96
97 /**
98 * Global option: timestamp of the last daily detection sweep.
99 */
100 const OPTION_LAST_SWEEP = 'mainwp_url_correct_last_sweep';
101
102 /**
103 * Global option: set when the queue may contain entries, so the minutely
104 * drain can skip its options-table query entirely in the steady state.
105 */
106 const OPTION_HAS_QUEUE = 'mainwp_url_correct_has_queue';
107
108 /**
109 * Freshness gate for the daily sweep: only rows synced within this many days.
110 */
111 const SWEEP_FRESH_DAYS = 30;
112
113 /**
114 * Flip-flop damper: at most one applied correction per site in this many days.
115 */
116 const DAMPER_DAYS = 14;
117
118 /**
119 * Do not re-verify a failed candidate more often than this many days.
120 */
121 const RETRY_DAYS = 30;
122
123 /**
124 * Maximum applied-corrections history entries kept per site.
125 */
126 const HISTORY_MAX_ENTRIES = 20;
127
128 /**
129 * Timeout ( seconds ) for the verify-before-apply probe. The shared fetch
130 * plumbing defaults to 20 hours ( sized for upgrades and backups ); a
131 * reachability probe running on the minutely queue drain must be bounded
132 * much tighter so a stalling host cannot park a cron worker.
133 */
134 const VERIFY_TIMEOUT = 120;
135
136 /**
137 * Singleton instance.
138 *
139 * @var null|self
140 */
141 private static $instance = null;
142
143 /**
144 * Get instance.
145 *
146 * @return self
147 */
148 public static function instance() {
149 if ( null === static::$instance ) {
150 static::$instance = new self();
151 }
152 return static::$instance;
153 }
154
155 /**
156 * Constructor.
157 *
158 * Intentionally empty: the regular-sequence dispatcher instantiates the
159 * callback class on every minutely tick ( see
160 * MainWP_System_Cron_Jobs::is_process_callable() ), so registering hooks
161 * here would stack duplicates. All wiring happens in
162 * MainWP_System_Cron_Jobs::init_cron_jobs().
163 */
164 public function __construct() {
165 }
166
167 /**
168 * Register the queue drain into the regular sequence process dispatcher.
169 *
170 * Hooked to the mainwp_register_regular_sequence_process filter.
171 *
172 * @param array $list_values Registered processes list.
173 *
174 * @return array Registered processes list.
175 */
176 public static function hook_regular_sequence_process( $list_values ) {
177 if ( is_array( $list_values ) ) {
178 $list_values['site_url_correct'] = array(
179 'priority' => 10,
180 'callback' => array( __CLASS__, 'run_scheduled_tasks' ), // must be array( class_name, method ).
181 );
182 }
183 return $list_values;
184 }
185
186 /**
187 * Entry point for the regular-sequence dispatcher. Runs under both WP
188 * cron and server cron ( cron/generalschedules.php ): performs the daily
189 * detection sweep when due, then drains the verify + apply queue.
190 */
191 public function run_scheduled_tasks() {
192 $last_sweep = (int) get_option( self::OPTION_LAST_SWEEP, 0 );
193 if ( time() - $last_sweep >= DAY_IN_SECONDS ) {
194 MainWP_Utility::update_option( self::OPTION_LAST_SWEEP, time() );
195 $this->cron_sweep();
196 }
197 $this->process_queue();
198 }
199
200 /**
201 * Classify the difference between the stored URL and the child-reported URL.
202 *
203 * Compares scheme + host + port only; paths are deliberately ignored
204 * because subdirectory installs legitimately report a siteurl with a path
205 * while the dashboard stores the home URL.
206 *
207 * @param string $stored_url Stored dashboard URL ( mainwp_wp.url ).
208 * @param string $reported_url Child-reported URL ( mainwp_wp.siteurl ).
209 *
210 * @return string One of the CATEGORY_* constants.
211 */
212 public static function classify( $stored_url, $reported_url ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity -- NOSONAR - straightforward decision table.
213 $stored = static::parse_url_parts( $stored_url );
214 $reported = static::parse_url_parts( $reported_url );
215
216 if ( empty( $stored ) || empty( $reported ) ) {
217 return self::CATEGORY_OTHER;
218 }
219
220 if ( $stored['port'] !== $reported['port'] ) {
221 return self::CATEGORY_OTHER;
222 }
223
224 if ( $stored['host'] === $reported['host'] ) {
225 if ( $stored['scheme'] === $reported['scheme'] ) {
226 return self::CATEGORY_IDENTICAL;
227 }
228 return 'https' === $reported['scheme'] ? self::CATEGORY_SCHEME_UPGRADE : self::CATEGORY_SCHEME_DOWNGRADE;
229 }
230
231 $stored_no_www = preg_replace( '/^www\./', '', $stored['host'] );
232 $reported_no_www = preg_replace( '/^www\./', '', $reported['host'] );
233
234 if ( '' !== $stored_no_www && $stored_no_www === $reported_no_www ) {
235 if ( $stored['scheme'] === $reported['scheme'] ) {
236 return self::CATEGORY_WWW_ONLY;
237 }
238 return 'https' === $reported['scheme'] ? self::CATEGORY_SCHEME_WWW_UPGRADE : self::CATEGORY_SCHEME_WWW_DOWNGRADE;
239 }
240
241 return self::CATEGORY_OTHER;
242 }
243
244 /**
245 * Whether a category may be applied automatically.
246 *
247 * Safe categories only: www changes and http -> https upgrades. Scheme
248 * downgrades and cross-domain differences are never auto-applied.
249 *
250 * @param string $category Category constant.
251 *
252 * @return bool True when the category is safe to auto-apply.
253 */
254 public static function is_auto_fixable_category( $category ) {
255 return in_array( $category, array( self::CATEGORY_WWW_ONLY, self::CATEGORY_SCHEME_UPGRADE, self::CATEGORY_SCHEME_WWW_UPGRADE ), true );
256 }
257
258 /**
259 * Whether a category is a scheme/www-class mismatch ( expressible via the
260 * Edit screen protocol + www dropdowns ). Cross-domain differences are not.
261 *
262 * @param string $category Category constant.
263 *
264 * @return bool True for scheme/www-class categories.
265 */
266 public static function is_scheme_www_category( $category ) {
267 return in_array(
268 $category,
269 array(
270 self::CATEGORY_WWW_ONLY,
271 self::CATEGORY_SCHEME_UPGRADE,
272 self::CATEGORY_SCHEME_DOWNGRADE,
273 self::CATEGORY_SCHEME_WWW_UPGRADE,
274 self::CATEGORY_SCHEME_WWW_DOWNGRADE,
275 ),
276 true
277 );
278 }
279
280 /**
281 * Build the corrected candidate URL: reported scheme + host, stored path.
282 *
283 * The stored URL's path ( including its trailing slash ) is preserved;
284 * only scheme and host are taken from the child-reported URL.
285 *
286 * @param string $stored_url Stored dashboard URL.
287 * @param string $reported_url Child-reported URL.
288 *
289 * @return string Candidate URL, or empty string when not buildable.
290 */
291 public static function detected_candidate_url( $stored_url, $reported_url ) {
292 $reported = static::parse_url_parts( $reported_url );
293 if ( empty( $reported ) ) {
294 return '';
295 }
296
297 $port = ! empty( $reported['port'] ) ? ':' . $reported['port'] : '';
298 $path = wp_parse_url( trim( (string) $stored_url ), PHP_URL_PATH );
299 $path = is_string( $path ) ? $path : '';
300
301 return $reported['scheme'] . '://' . $reported['host'] . $port . $path;
302 }
303
304 /**
305 * Layer 1: the URL the browser-side Jump to WP Admin form should target.
306 *
307 * When the stored URL and the child-reported URL differ only within a safe
308 * scheme/www category, returns the canonical scheme + host with the stored
309 * path preserved. In every other case ( identical, locked, downgrade,
310 * cross-domain, kill-switch filter, missing data ) returns the stored URL
311 * exactly like the previous `$website->url ?: $website->siteurl` behavior.
312 *
313 * Never used for server-side traffic: backup downloads and premium-update
314 * requests must keep using the stored URL.
315 *
316 * @param object $website Child site object ( needs url, siteurl, id ).
317 *
318 * @return string Base URL for the browser-bound form ( no trailing-slash changes ).
319 */
320 public static function browser_target_url( $website ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity -- NOSONAR - guard clauses.
321 $stored = isset( $website->url ) && '' !== $website->url ? $website->url : ( isset( $website->siteurl ) ? $website->siteurl : '' );
322
323 if ( '' === $stored || ! isset( $website->siteurl ) || '' === $website->siteurl ) {
324 return $stored;
325 }
326
327 $category = static::classify( $stored, $website->siteurl );
328
329 if ( ! static::is_auto_fixable_category( $category ) ) {
330 return $stored;
331 }
332
333 /**
334 * Filter: mainwp_open_site_use_reported_url
335 *
336 * Kill-switch for routing the Jump to WP Admin login form to the
337 * child-reported canonical URL when the stored URL differs only by
338 * scheme/www.
339 *
340 * @param bool $enabled Default true.
341 * @param object $website Child site object.
342 *
343 * @since 6.2
344 */
345 if ( ! apply_filters( 'mainwp_open_site_use_reported_url', true, $website ) ) {
346 return $stored;
347 }
348
349 if ( static::is_locked( $website ) ) {
350 return $stored;
351 }
352
353 $candidate = static::detected_candidate_url( $stored, $website->siteurl );
354 if ( '' === $candidate ) {
355 return $stored;
356 }
357
358 return esc_url_raw( $candidate );
359 }
360
361 /**
362 * Layer 3: correct a user-entered URL against the child-reported URL at
363 * registration time.
364 *
365 * Adopts the reported www variant and http -> https scheme upgrades. Never
366 * downgrades https -> http ( the entered scheme is kept ) and never crosses
367 * domains. Returns the entered URL unchanged in all other cases.
368 *
369 * @param string $entered_url User-entered URL.
370 * @param string $reported_url Child-reported URL.
371 * @param bool $allow_scheme_upgrade Optional. Whether an http -> https
372 * upgrade may be adopted. Default true.
373 *
374 * @return string Corrected URL or the original entered URL.
375 */
376 public static function correct_entered_url( $entered_url, $reported_url, $allow_scheme_upgrade = true ) {
377 if ( ! is_string( $entered_url ) || ! is_string( $reported_url ) ) {
378 return $entered_url;
379 }
380
381 $entered_url = trim( $entered_url );
382 $reported_url = trim( $reported_url );
383
384 $category = static::classify( $entered_url, $reported_url );
385
386 if ( ! static::is_scheme_www_category( $category ) ) {
387 return $entered_url;
388 }
389
390 $entered = static::parse_url_parts( $entered_url );
391 $reported = static::parse_url_parts( $reported_url );
392
393 // Never downgrade the scheme: keep the entered scheme unless the child reports https.
394 $scheme = $allow_scheme_upgrade && 'https' === $reported['scheme'] ? 'https' : $entered['scheme'];
395 $port = ! empty( $entered['port'] ) ? ':' . $entered['port'] : '';
396 $path = wp_parse_url( $entered_url, PHP_URL_PATH );
397 $path = is_string( $path ) ? $path : '';
398
399 return $scheme . '://' . $reported['host'] . $port . $path;
400 }
401
402 /**
403 * Whether automatic URL correction is locked for a site.
404 *
405 * @param object|int $website Child site object or ID.
406 *
407 * @return bool True when locked.
408 */
409 public static function is_locked( $website ) {
410 $value = MainWP_DB::instance()->get_website_option( $website, self::OPTION_LOCK, 0 );
411 return ! empty( $value );
412 }
413
414 /**
415 * Set or unset the per-site URL lock. Locking also clears any queued correction.
416 *
417 * @param object|int $website Child site object or ID.
418 * @param bool $locked Lock state.
419 */
420 public static function set_lock( $website, $locked ) {
421 MainWP_DB::instance()->update_website_option( $website, self::OPTION_LOCK, $locked ? 1 : 0 );
422 if ( $locked ) {
423 MainWP_DB::instance()->update_website_option( $website, self::OPTION_PENDING, '' );
424 }
425 }
426
427 /**
428 * Record that a user ( UI, REST API, Abilities API, WP-CLI ) explicitly set
429 * a site URL. When the chosen URL diverges from the child-reported
430 * canonical, the per-site lock is set automatically so the corrector never
431 * reverts a deliberate choice.
432 *
433 * @param object|int $website Child site object ( needs id + siteurl ) or site ID.
434 * @param string $new_url The URL the user set.
435 */
436 public static function after_user_set_url( $website, $new_url ) {
437 if ( is_numeric( $website ) ) {
438 $website = MainWP_DB::instance()->get_website_by_id( intval( $website ) );
439 }
440
441 if ( empty( $website ) || ! is_object( $website ) || empty( $new_url ) || ! is_string( $new_url ) ) {
442 return;
443 }
444
445 $reported = isset( $website->siteurl ) ? $website->siteurl : '';
446 if ( '' === $reported ) {
447 return;
448 }
449
450 $category = static::classify( $new_url, $reported );
451 if ( self::CATEGORY_IDENTICAL === $category ) {
452 return;
453 }
454
455 static::set_lock( $website, true );
456 MainWP_Logger::instance()->info_for_website( $website, 'URL AUTO-CORRECT', 'Lock enabled: user-set URL [' . $new_url . '] differs from the child-reported address [' . $reported . '].' );
457 }
458
459 /**
460 * Daily sweep ( Layer 2 detection ). Invoked via run_scheduled_tasks()
461 * once per day.
462 *
463 * One candidate SQL query fleet-wide; classification in PHP. Actionable
464 * safe-category mismatches are queued for the throttled verify + apply
465 * drain when automatic correction is enabled; otherwise the finding is
466 * journaled to the per-site suggest option ( silent, support-facing ).
467 * The first run doubles as the backfill for the existing install base.
468 */
469 public function cron_sweep() { // phpcs:ignore Generic.Metrics.CyclomaticComplexity -- NOSONAR - guard clauses.
470 $rows = MainWP_DB::instance()->get_websites_with_url_siteurl_mismatch( self::SWEEP_FRESH_DAYS );
471 if ( empty( $rows ) ) {
472 return;
473 }
474
475 $auto_enabled = (int) get_option( self::OPTION_AUTO_ENABLED, 1 );
476
477 foreach ( $rows as $row ) {
478 $category = static::classify( $row->url, $row->siteurl );
479
480 if ( ! static::is_scheme_www_category( $category ) ) {
481 continue; // identical ( slash/case only ) or cross-domain: nothing to do here.
482 }
483
484 if ( static::is_locked( $row->id ) ) {
485 continue;
486 }
487
488 $candidate = static::detected_candidate_url( $row->url, $row->siteurl );
489 if ( '' === $candidate ) {
490 continue;
491 }
492
493 if ( '/' !== substr( $candidate, -1 ) ) {
494 $candidate .= '/'; // canonical form: stored URLs always carry a trailing slash.
495 }
496
497 if ( ! $auto_enabled || ! static::is_auto_fixable_category( $category ) ) {
498 static::journal_suggest( $row->id, $candidate, $category, $auto_enabled ? 'policy' : 'suggest_only_mode' );
499 continue;
500 }
501
502 // Re-verify damper: skip candidates that recently failed verification.
503 $suggest = MainWP_DB::instance()->get_website_option( $row->id, self::OPTION_SUGGEST, null, true );
504 if ( is_array( $suggest ) && isset( $suggest['reason'], $suggest['candidate'], $suggest['ts'] ) && 'verify_failed' === $suggest['reason'] && $suggest['candidate'] === $candidate && ( time() - intval( $suggest['ts'] ) ) < self::RETRY_DAYS * DAY_IN_SECONDS ) {
505 continue;
506 }
507
508 $pending = MainWP_DB::instance()->get_website_option( $row->id, self::OPTION_PENDING, null, true );
509 if ( is_array( $pending ) && isset( $pending['candidate'] ) && $pending['candidate'] === $candidate ) {
510 continue; // already queued.
511 }
512
513 MainWP_DB::instance()->update_website_option(
514 $row->id,
515 self::OPTION_PENDING,
516 wp_json_encode(
517 array(
518 'candidate' => $candidate,
519 'category' => $category,
520 'ts' => time(),
521 )
522 )
523 );
524 MainWP_Utility::update_option( self::OPTION_HAS_QUEUE, 1 );
525 }
526 }
527
528 /**
529 * Throttled verify + apply drain ( Layer 2 apply ).
530 *
531 * Registered into the minutely regular-sequence dispatcher; each
532 * invocation processes a small batch because every verification is a full
533 * authed HTTP round-trip to the child site.
534 */
535 public function process_queue() {
536 if ( ! (int) get_option( self::OPTION_AUTO_ENABLED, 1 ) ) {
537 return;
538 }
539
540 if ( ! get_option( self::OPTION_HAS_QUEUE ) ) {
541 return; // steady state: nothing queued, skip the options-table query entirely.
542 }
543
544 /**
545 * Filter: mainwp_url_correct_batch_size
546 *
547 * Number of queued URL corrections verified per drain invocation.
548 *
549 * @param int $batch_size Default 3.
550 *
551 * @since 6.2
552 */
553 $batch = (int) apply_filters( 'mainwp_url_correct_batch_size', 3 );
554 $ids = MainWP_DB::instance()->get_website_ids_with_nonempty_option( self::OPTION_PENDING, max( 1, $batch ) );
555
556 if ( empty( $ids ) ) {
557 MainWP_Utility::update_option( self::OPTION_HAS_QUEUE, 0 );
558 return;
559 }
560
561 foreach ( $ids as $site_id ) {
562 $this->process_queued_site( intval( $site_id ) );
563 }
564 }
565
566 /**
567 * Verify and apply one queued correction.
568 *
569 * @param int $site_id Child site ID.
570 */
571 private function process_queued_site( $site_id ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity -- NOSONAR - sequential guard pipeline.
572 $website = MainWP_DB::instance()->get_website_by_id( $site_id );
573
574 if ( empty( $website ) ) {
575 MainWP_DB::instance()->update_website_option( $site_id, self::OPTION_PENDING, '' );
576 return;
577 }
578
579 // Re-derive everything from the current columns; the queue entry may be stale.
580 $category = static::classify( $website->url, isset( $website->siteurl ) ? $website->siteurl : '' );
581
582 if ( ! static::is_auto_fixable_category( $category ) || static::is_locked( $website ) ) {
583 MainWP_DB::instance()->update_website_option( $website, self::OPTION_PENDING, '' );
584 return;
585 }
586
587 $candidate = static::detected_candidate_url( $website->url, $website->siteurl );
588 if ( '' === $candidate ) {
589 MainWP_DB::instance()->update_website_option( $website, self::OPTION_PENDING, '' );
590 return;
591 }
592
593 if ( '/' !== substr( $candidate, -1 ) ) {
594 $candidate .= '/'; // stored URLs always carry a trailing slash.
595 }
596
597 // Flip-flop damper: at most one applied correction per DAMPER_DAYS.
598 $history = MainWP_DB::instance()->get_website_option( $website, self::OPTION_HISTORY, null, true );
599 $history = is_array( $history ) ? $history : array();
600 foreach ( $history as $entry ) {
601 if ( isset( $entry['ts'] ) && ( time() - intval( $entry['ts'] ) ) < self::DAMPER_DAYS * DAY_IN_SECONDS ) {
602 static::journal_suggest( $website->id, $candidate, $category, 'damper' );
603 MainWP_DB::instance()->update_website_option( $website, self::OPTION_PENDING, '' );
604 return;
605 }
606 }
607
608 // Duplicate guard: never collapse two site rows onto the same URL.
609 if ( MainWP_DB::instance()->get_website_by_exact_url_excluding_id( $candidate, $website->id ) ) {
610 static::journal_suggest( $website->id, $candidate, $category, 'duplicate_site' );
611 MainWP_DB::instance()->update_website_option( $website, self::OPTION_PENDING, '' );
612 return;
613 }
614
615 // Verify-before-apply: one authed request against the candidate URL.
616 if ( ! static::verify_candidate_url( $website, $candidate ) ) {
617 static::journal_suggest( $website->id, $candidate, $category, 'verify_failed' );
618 MainWP_DB::instance()->update_website_option( $website, self::OPTION_PENDING, '' );
619 return;
620 }
621
622 $old_url = $website->url;
623
624 MainWP_DB::instance()->update_website_values( $website->id, array( 'url' => $candidate ) );
625
626 $history[] = array(
627 'old' => $old_url,
628 'new' => $candidate,
629 'category' => $category,
630 'source' => 'auto',
631 'ts' => time(),
632 );
633 if ( count( $history ) > self::HISTORY_MAX_ENTRIES ) {
634 $history = array_slice( $history, - self::HISTORY_MAX_ENTRIES );
635 }
636 MainWP_DB::instance()->update_website_option( $website, self::OPTION_HISTORY, wp_json_encode( $history ) );
637 MainWP_DB::instance()->update_website_option( $website, self::OPTION_PENDING, '' );
638 MainWP_DB::instance()->update_website_option( $website, self::OPTION_SUGGEST, '' );
639
640 MainWP_Manage_Sites_List_Table::invalidate_manage_sites_cache();
641
642 MainWP_Logger::instance()->info_for_website( $website, 'URL AUTO-CORRECT', 'Stored URL corrected from [' . $old_url . '] to [' . $candidate . '] (the child site reports this address).' );
643
644 /**
645 * Action: mainwp_site_url_corrected
646 *
647 * Fires after the stored URL of a child site was corrected to the
648 * child-reported canonical address.
649 *
650 * @param int $website_id Child site ID.
651 * @param string $old_url Previous stored URL.
652 * @param string $new_url New stored URL.
653 * @param string $source Correction source ( 'auto' ).
654 *
655 * @since 6.2
656 */
657 do_action( 'mainwp_site_url_corrected', (int) $website->id, $old_url, $candidate, 'auto' );
658 }
659
660 /**
661 * Verify a candidate URL with one authed request ( 'stats' ) against a
662 * clone of the website object. The clone keeps every connection property
663 * ( keys, admin name, SSL settings, HTTP auth, IPv4 flag ); only the URL
664 * is swapped.
665 *
666 * @param object $website Child site object.
667 * @param string $candidate Candidate URL ( trailing-slashed ).
668 *
669 * @return bool True when the child answered the authed request on the candidate URL.
670 */
671 private static function verify_candidate_url( $website, $candidate ) {
672 $probe = clone $website;
673 $probe->url = $candidate;
674
675 // Hard ceiling for the probe: runs last ( PHP_INT_MAX ) and clamps, so
676 // other filter callbacks may shorten the probe but never extend it.
677 $bound_timeout = static function ( $timeout ) {
678 $timeout = (int) $timeout;
679 if ( $timeout <= 0 || $timeout > self::VERIFY_TIMEOUT ) {
680 return self::VERIFY_TIMEOUT;
681 }
682 return $timeout;
683 };
684 add_filter( 'mainwp_fetch_url_site_timeout', $bound_timeout, PHP_INT_MAX );
685
686 try {
687 $information = MainWP_Connect::fetch_url_authed( $probe, 'stats' );
688 } catch ( MainWP_Exception $e ) {
689 return false;
690 } catch ( \Exception $e ) {
691 return false;
692 } finally {
693 remove_filter( 'mainwp_fetch_url_site_timeout', $bound_timeout, PHP_INT_MAX );
694 }
695
696 return is_array( $information ) && ! isset( $information['error'] );
697 }
698
699 /**
700 * Journal a suggestion / skip reason to the per-site suggest option.
701 * Silent by design: only support ( and the Edit screen hint logic ) read it.
702 *
703 * @param int $site_id Child site ID.
704 * @param string $candidate Candidate URL.
705 * @param string $category Category constant.
706 * @param string $reason Reason slug ( suggest_only_mode|policy|damper|duplicate_site|verify_failed ).
707 */
708 private static function journal_suggest( $site_id, $candidate, $category, $reason ) {
709 $current = MainWP_DB::instance()->get_website_option( $site_id, self::OPTION_SUGGEST, null, true );
710 if ( is_array( $current ) && isset( $current['candidate'], $current['reason'] ) && $current['candidate'] === $candidate && $current['reason'] === $reason && 'verify_failed' !== $reason ) {
711 return; // unchanged; avoid daily rewrites. verify_failed always rewrites so its retry timestamp stays fresh.
712 }
713 MainWP_DB::instance()->update_website_option(
714 $site_id,
715 self::OPTION_SUGGEST,
716 wp_json_encode(
717 array(
718 'candidate' => $candidate,
719 'category' => $category,
720 'reason' => $reason,
721 'ts' => time(),
722 )
723 )
724 );
725 }
726
727 /**
728 * Parse a URL into normalized scheme / host / port parts.
729 *
730 * @param string $url URL to parse.
731 *
732 * @return array Empty array when invalid; otherwise scheme ( http|https,
733 * lowercase ), host ( lowercase ) and port ( int, 0 = none ).
734 */
735 private static function parse_url_parts( $url ) {
736 if ( ! is_string( $url ) || '' === trim( $url ) ) {
737 return array();
738 }
739
740 $parts = wp_parse_url( trim( $url ) );
741
742 if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
743 return array();
744 }
745
746 $scheme = strtolower( $parts['scheme'] );
747 if ( 'http' !== $scheme && 'https' !== $scheme ) {
748 return array();
749 }
750
751 return array(
752 'scheme' => $scheme,
753 'host' => strtolower( $parts['host'] ),
754 'port' => isset( $parts['port'] ) ? intval( $parts['port'] ) : 0,
755 );
756 }
757 }
758