1 && substr( $file, -strlen( $tail ) ) === $tail ) { return dirname( substr( $file, 0, -strlen( $tail ) ) ); } } // includes/ -> plugin -> plugins -> content. return dirname( $file, 3 ); } /** * First meaningful word of a flag file, tolerating comments and KEY=VALUE. * * @param string $raw File contents. * @return string Lowercased, or '' when nothing usable was found. */ private static function first_word( string $raw ): string { foreach ( preg_split( '/\r\n|\r|\n/', $raw ) as $line ) { $line = trim( (string) $line ); if ( '' === $line || 0 === strpos( $line, '#' ) || 0 === strpos( $line, ';' ) ) { continue; } if ( false !== strpos( $line, '=' ) ) { $line = trim( substr( $line, strpos( $line, '=' ) + 1 ) ); } return strtolower( trim( $line, "\"' \t" ) ); } return ''; } /** * Stop a copy emailing anybody, and say what it swallowed. * * WHY THIS IS NOT OPTIONAL ON A REFRESHED COPY * * A copy holds the primary's USERS, ORDERS AND HOOKS. Nothing in WordPress * distinguishes "this is a test database" from the real one, so a password * reset, an order confirmation, a comment notification or any plugin's * lifecycle mail goes to the real customer, from a machine nobody thinks of * as live. It is the one suppression whose blast radius reaches people * outside the estate. * * WHY IT IS NOT A BLANKET BLOCK ON EVERY STANDBY * * A HOT STANDBY is expected to become the live site, and a site that cannot * send a password reset the moment it takes over is broken exactly when it * matters. So this is driven by the caller's purpose, not by is_standby() * alone: refreshed copies are silenced, hot standbys are not. * * WHY IT LOGS RATHER THAN JUST DROPPING * * A copy that silently eats mail looks identical to a copy whose mail is * broken. When somebody asks "did the test send the invoice?", the answer has * to be findable. wp_mail_failed carries the recipient and subject so the * record says what was stopped, not merely that something was. * * @param string $reason Human-readable reason, used in the failure notice. * @return void */ public static function block_outbound_mail( string $reason ): void { add_filter( 'pre_wp_mail', static function ( $short_circuit, $atts ) use ( $reason ) { $to = is_array( $atts['to'] ?? '' ) ? implode( ', ', $atts['to'] ) : (string) ( $atts['to'] ?? '' ); $subject = (string) ( $atts['subject'] ?? '' ); if ( function_exists( 'error_log' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- operational: a copy that silently eats mail is indistinguishable from one whose mail is broken error_log( '[CloudScale] Outbound email BLOCKED on a copy: to=' . $to . ' subject=' . $subject . ' (' . $reason . ')' ); } // true short-circuits wp_mail() and reports success to the caller, // which is deliberate: a plugin that treats a mail failure as a // hard error would otherwise break checkout flows on the copy, and // the point is to make the copy USABLE for testing, not to make it // throw. The log and the hook are where the truth lives. return true; }, 5, 2 ); } /** * Disarm a cron hook that must not run on a copy. * * ON EVERY REQUEST, not at schedule time. A standby does not acquire a schedule * by being configured; it acquires one by RESTORING THE PRIMARY'S DATABASE, * which is neither activation nor a settings save. The backup plugin learned * this the hard way — both its events were found armed on QA, overdue and due * "now", held off only by that box happening to have no wp-cron runner. * * wp_next_scheduled() reads the already-loaded cron option, so this costs * nothing on the requests where there is nothing to do, which is all of them. * * @param string[] $hooks Cron hook names. * @param string $source Plugin name, for the log line. * @return string[] The hooks actually cleared. */ public static function disarm( array $hooks, string $source ): array { if ( ! self::is_standby() ) { return array(); } $cleared = array(); foreach ( $hooks as $hook ) { if ( wp_next_scheduled( $hook ) ) { wp_clear_scheduled_hook( $hook ); $cleared[] = $hook; } } if ( array() !== $cleared && function_exists( 'error_log' ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- operational: says which inherited schedules were removed and why error_log( '[' . $source . '] Standby: cleared inherited schedule(s) ' . implode( ', ', $cleared ) . '. They arrived with the primary\'s database.' ); } return $cleared; } } }