get_error_code() ) { // Plugin code is about to go quiet; the file itself explains a leftover copy. self::remember_issue( 'remove', $result->get_error_code() ); } } /** * Option deletion hook, narrowed to the connection option. * * @param string $option Option name. * @return void */ public static function on_deleted_option( $option ): void { if ( 'hatch_frontend_url' === $option ) { self::reconcile(); } } /** * Dashboard entry point. Only an administrator's request may write the file, so a * signed-out call to admin-ajax.php or admin-post.php never reaches the filesystem. * * @return void */ public static function on_admin_init(): void { if ( current_user_can( 'manage_options' ) ) { self::reconcile(); } } /** * Hook entry point that ignores the arguments WordPress passes to option and * admin_init hooks, so none of them can be mistaken for a filesystem object. * * @return void */ public static function on_connection_changed(): void { self::reconcile(); } /** * Bring the file in line with what the site needs right now. Cheap when nothing * has to change: one option read and one file stat. * * @param object|null $fs Optional filesystem object (tests). Defaults to WP_Filesystem. * @return void */ public static function reconcile( $fs = null ): void { $state = self::inspect( $fs ); if ( 'foreign' === $state ) { self::clear_issue(); return; } if ( self::should_exist() ) { if ( 'current' === $state ) { self::clear_issue(); return; } $result = self::install( $fs ); $action = 'absent' === $state ? 'install' : 'update'; } else { if ( 'absent' === $state ) { self::clear_issue(); return; } $result = self::remove( $fs ); $action = 'remove'; } // A foreign file is left alone on purpose, and a site that turned file changes off // (Hatch's own Fortress "disable file edit" switch, or DISALLOW_FILE_MODS) made that // choice deliberately, so neither is reported as a failure. $quiet = array( 'hatch_mu_foreign', 'hatch_mu_file_mods_disabled' ); if ( is_wp_error( $result ) && ! in_array( $result->get_error_code(), $quiet, true ) ) { self::remember_issue( $action, $result->get_error_code() ); return; } self::clear_issue(); } /** * Does this site, or on a network any other site running Hatch, need the file? * * @return bool */ public static function should_exist(): bool { if ( self::is_needed() ) { return true; } return is_multisite() && self::other_sites_need_it( get_current_blog_id() ); } /** * Is the helper needed on the current site? * * It is needed when a public frontend is connected AND the site's own address is * a host that cannot be reached from outside. On a normal public domain * WordPress already emits correct URLs and Hatch_Media_Rewriter covers the rest, * so installing the helper there would only be clutter. * * The hatch_url_rewriter_needed filter lets a host or a developer override the * answer in either direction. * * @return bool */ public static function is_needed(): bool { $frontend = self::frontend_url(); // The options, not home_url(): other code may filter home_url() to the // frontend, which would hide the site's real address. $needed = self::decide_needed( $frontend, (string) get_option( 'home', '' ), (string) get_option( 'siteurl', '' ) ); /** * Filters whether the Hatch URL rewriter mu-plugin should be present. * * @param bool $needed Whether the helper is needed. * @param string $frontend The connected frontend URL, empty when none. */ return (bool) apply_filters( 'hatch_url_rewriter_needed', $needed, $frontend ); } /** * The decision, free of WordPress state so it can be tested on its own. * * @param string $frontend Connected frontend URL, may be empty. * @param string $home Value of home_url(). * @param string $site Value of site_url(). * @return bool */ public static function decide_needed( string $frontend, string $home, string $site ): bool { $frontend_host = self::host_of( $frontend ); if ( '' === $frontend_host ) { return false; } // The helper refuses a frontend that is itself private (it would only swap one // unreachable address for another), so there is nothing for it to do. if ( self::is_private_host( $frontend_host ) ) { return false; } return self::is_stale_host( self::host_of( $home ) ) || self::is_stale_host( self::host_of( $site ) ); } /** * Hosts the helper rewrites away from: local machines and expired tunnels. * * @param string $host Lower-case host name, no port. * @return bool */ public static function is_stale_host( string $host ): bool { if ( '' === $host ) { return false; } if ( self::is_private_host( $host ) ) { return true; } return '.trycloudflare.com' === substr( $host, -18 ); // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Host-name comparison for temporary tunnels, nothing is loaded. } /** * Local-only hosts, which can never be a usable public frontend. * * @param string $host Lower-case host name, no port. * @return bool */ public static function is_private_host( string $host ): bool { return in_array( $host, array( 'localhost', '127.0.0.1', 'host.docker.internal' ), true ); } /** * Lower-case host of a URL, empty when there is none. * * @param string $url URL. * @return string */ public static function host_of( string $url ): string { $host = wp_parse_url( trim( $url ), PHP_URL_HOST ); return is_string( $host ) ? strtolower( $host ) : ''; } /** * Is this file content something Hatch wrote? * * @param string $contents Whole file content. * @return bool */ public static function is_managed_contents( string $contents ): bool { return false !== strpos( substr( $contents, 0, self::MARKER_WINDOW ), self::MARKER ); } /** * Classify what is in the mu-plugins folder. * * @param bool $exists Whether a file of that name exists. * @param string|null $md5 md5 of that file, null when unreadable. * @param string $source_md5 md5 of the bundled copy. * @param string|null $contents Content of the file, only needed when the md5 matches nothing known. * @return string One of absent, current, managed (Hatch-written, out of date), foreign. */ public static function classify( bool $exists, ?string $md5, string $source_md5, ?string $contents ): string { if ( ! $exists ) { return 'absent'; } if ( null !== $md5 && $md5 === $source_md5 ) { return 'current'; } if ( null !== $contents && self::is_managed_contents( $contents ) ) { return 'managed'; } return 'foreign'; } /** * Look at the file on disk. * * @param object|null $fs Optional filesystem object. * @return string absent, current, managed or foreign. */ public static function inspect( $fs = null ): string { $target = self::target_path(); if ( ! file_exists( $target ) ) { return 'absent'; } $md5 = is_readable( $target ) ? md5_file( $target ) : false; $source_md5 = is_readable( self::source_path() ) ? md5_file( self::source_path() ) : false; $md5 = false === $md5 ? null : $md5; $source_md5 = false === $source_md5 ? '' : $source_md5; $state = self::classify( true, $md5, $source_md5, null ); if ( 'foreign' !== $state ) { return $state; } // Not byte-identical to anything known: read it to look for the marker. $fs = $fs ? $fs : self::filesystem(); if ( is_wp_error( $fs ) ) { return 'foreign'; } $contents = $fs->get_contents( $target ); return self::classify( true, $md5, $source_md5, is_string( $contents ) ? $contents : null ); } /** * Write the bundled helper into the mu-plugins folder. * * @param object|null $fs Optional filesystem object (tests). Defaults to WP_Filesystem. * @return true|WP_Error WP_Error code hatch_mu_foreign means a file that is not Hatch's is in the way and was left alone. */ public static function install( $fs = null ) { // DISALLOW_FILE_MODS (or a filter on file_mod_allowed) says nothing on this site may change files from the dashboard. if ( ! wp_is_file_mod_allowed( 'hatch_install_mu_plugin' ) ) { return new WP_Error( 'hatch_mu_file_mods_disabled', __( 'File changes are turned off on this site, so Hatch did not write the URL helper.', 'hatch-connect' ) ); } $fs = $fs ? $fs : self::filesystem(); if ( is_wp_error( $fs ) ) { return $fs; } $source = $fs->get_contents( self::source_path() ); if ( ! is_string( $source ) || '' === $source ) { return new WP_Error( 'hatch_mu_source_missing', __( 'The bundled URL helper file is missing from the Hatch plugin folder.', 'hatch-connect' ) ); } $dir = self::dir(); $target = self::target_path(); if ( $fs->exists( $target ) ) { $existing = $fs->get_contents( $target ); if ( ! is_string( $existing ) || ! self::is_managed_contents( $existing ) ) { return new WP_Error( 'hatch_mu_foreign', __( 'A file with that name already exists and was not written by Hatch, so Hatch left it alone.', 'hatch-connect' ) ); } } if ( ! $fs->is_dir( $dir ) && ! $fs->mkdir( $dir, defined( 'FS_CHMOD_DIR' ) ? FS_CHMOD_DIR : false ) ) { return new WP_Error( 'hatch_mu_dir_missing', __( 'The mu-plugins folder does not exist and could not be created.', 'hatch-connect' ) ); } // Write beside the target and move into place so a request that loads // mu-plugins halfway through never sees a truncated PHP file. $temp = $target . '.tmp'; if ( ! $fs->put_contents( $temp, $source, defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : false ) ) { return new WP_Error( 'hatch_mu_not_writable', __( 'The mu-plugins folder is not writable.', 'hatch-connect' ) ); } if ( ! $fs->move( $temp, $target, true ) ) { $fs->delete( $temp ); return new WP_Error( 'hatch_mu_not_writable', __( 'The mu-plugins folder is not writable.', 'hatch-connect' ) ); } return true; } /** * Remove the helper, but only when Hatch wrote it. * * @param object|null $fs Optional filesystem object (tests). Defaults to WP_Filesystem. * @return true|WP_Error True when the file is gone (or was never there). hatch_mu_foreign means it is not Hatch's and was left alone. */ public static function remove( $fs = null ) { $target = self::target_path(); $fs = $fs ? $fs : self::filesystem(); if ( is_wp_error( $fs ) ) { // Nothing to do when there is no file, whatever the filesystem situation. return file_exists( $target ) ? $fs : true; } if ( ! $fs->exists( $target ) ) { return true; } $contents = $fs->get_contents( $target ); if ( ! is_string( $contents ) || ! self::is_managed_contents( $contents ) ) { return new WP_Error( 'hatch_mu_foreign', __( 'A file with that name exists but was not written by Hatch, so Hatch left it alone.', 'hatch-connect' ) ); } if ( ! $fs->delete( $target ) ) { return new WP_Error( 'hatch_mu_not_writable', __( 'The mu-plugins folder is not writable.', 'hatch-connect' ) ); } return true; } /** * Uninstall path: remove the helper. The plugin is being deleted, so no site can * still need it. The saved failure note is a per-site option and is deleted * with the other options by uninstall.php. * * @return void */ public static function remove_for_uninstall(): void { self::remove(); } /** * On a network the mu-plugins folder is shared. Does another site still run * Hatch with a connection that needs the helper? * * When the network is larger than the scan limit the answer is "yes", so the * file is kept: a leftover inert file is better than breaking someone's media. * * @param int $current_blog_id Site being deactivated or reconciled, excluded from the scan. * @return bool */ public static function other_sites_need_it( int $current_blog_id ): bool { if ( ! is_multisite() ) { return false; } $ids = get_sites( array( 'fields' => 'ids', 'number' => self::MULTISITE_SCAN_LIMIT + 1, ) ); if ( count( $ids ) > self::MULTISITE_SCAN_LIMIT ) { return true; } $basename = plugin_basename( HATCH_PLUGIN_FILE ); foreach ( $ids as $blog_id ) { if ( (int) $blog_id === $current_blog_id ) { continue; } switch_to_blog( (int) $blog_id ); $active = in_array( $basename, (array) get_option( 'active_plugins', array() ), true ) || array_key_exists( $basename, (array) get_site_option( 'active_sitewide_plugins', array() ) ); $needed = $active && self::is_needed(); restore_current_blog(); if ( $needed ) { return true; } } return false; } /** * Admin notice for a failed write, with the exact manual step. * * @return void */ public static function render_notice(): void { if ( ! current_user_can( 'activate_plugins' ) ) { return; } $issue = get_option( self::OPT_ISSUE, array() ); if ( ! is_array( $issue ) || empty( $issue['action'] ) || empty( $issue['code'] ) ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $shown = array( 'plugins' ); if ( ! $screen || ( ! in_array( $screen->id, $shown, true ) && false === strpos( $screen->id, 'hatch' ) ) ) { return; } $target = self::target_path(); $source = self::source_path(); ?>

' . esc_html( $target ) . '' ), array( 'code' => array() ) ); ?> ' . esc_html( $source ) . '', '' . esc_html( self::dir() ) . '' ), array( 'code' => array() ) ); ?>

$action, 'code' => $code, ); if ( get_option( self::OPT_ISSUE, array() ) !== $issue ) { update_option( self::OPT_ISSUE, $issue, false ); } } /** * Forget a failure that no longer applies. * * @return void */ private static function clear_issue(): void { if ( false !== get_option( self::OPT_ISSUE, false ) ) { delete_option( self::OPT_ISSUE ); } } }