/ with backups/, jobs/ and rollback/. * The name is random and stored in an option; guard files keep Apache, IIS and directory listings out. * nginx ignores .htaccess, so web_protected() probes the real server instead of assuming. * * @package Mench_Migrate */ defined( 'ABSPATH' ) || exit; // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are internal machine codes; screens and REST answers show only the escaped sentence from Mench_Migrate_Rest::message(). class Mench_Migrate_Paths { public const OPTION = 'mench_migrate_storage_dir'; public const PROBE_TRANSIENT = 'mench_migrate_web_protected'; /** Absolute path of the storage root, created on first use. */ public static function root(): string { self::ensure(); return self::root_path(); } public static function backups(): string { return self::root() . '/backups'; } public static function jobs(): string { return self::root() . '/jobs'; } public static function rollback(): string { return self::root() . '/rollback'; } /** Working folder of one job; the id may only contain letters, digits, dot, dash and underscore. */ public static function job_dir( string $job_id ): string { if ( ! preg_match( '/^[A-Za-z0-9][A-Za-z0-9._-]*$/', $job_id ) || false !== strpos( $job_id, '..' ) ) { throw new Mench_Migrate_Storage_Exception( 'Invalid job id.', 'paths' ); } return self::jobs() . '/' . $job_id; } /** Lowercase a-z0-9 string for unguessable file names. */ public static function random_suffix( int $len = 16 ): string { $out = ''; while ( strlen( $out ) < $len ) { $out .= strtolower( preg_replace( '/[^a-z0-9]/i', '', wp_generate_password( $len, false ) ) ); } return substr( $out, 0, $len ); } /** Creates the root, its three folders and the guard files. Idempotent. */ public static function ensure(): void { $name = (string) get_option( self::OPTION, '' ); if ( ! preg_match( '/^mench-migrate-[a-z0-9]{12}$/', $name ) ) { $name = 'mench-migrate-' . self::random_suffix( 12 ); update_option( self::OPTION, $name, false ); } $root = trailingslashit( WP_CONTENT_DIR ) . $name; foreach ( [ $root, $root . '/backups', $root . '/jobs', $root . '/rollback' ] as $dir ) { if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) { throw new Mench_Migrate_Storage_Exception( 'Cannot create the storage folder ' . $dir, 'paths' ); } } self::write_guard( $root . '/index.php', "\nRequire all denied\n\n\nOrder allow,deny\nDeny from all\n\n\nOptions -Indexes\n\n", true ); self::write_guard( $root . '/web.config', "\n\n", true ); } /** Cron hook that runs the protection probe outside any page render. */ public const PROBE_CRON = 'mench_migrate_probe'; /** * Whether the web server refuses to serve a file placed in the root, from the cached probe: true protected, false * served (nginx ignores .htaccess), null not known yet or could not be told. Never probes during a page render * unless $probe_now: a missing result schedules the probe on cron, and the dashboard asks GET /protection. */ public static function web_protected( bool $probe_now = false ): ?bool { $cached = get_transient( self::PROBE_TRANSIENT ); if ( false !== $cached ) { return '1' === (string) $cached ? true : ( '0' === (string) $cached ? false : null ); } if ( $probe_now ) { return self::probe(); } if ( function_exists( 'wp_next_scheduled' ) && ! wp_next_scheduled( self::PROBE_CRON ) ) { wp_schedule_single_event( time(), self::PROBE_CRON ); } return null; } /** * Probes once with a tiny file and caches the verdict: 401/403/404 protected (12 h); served with the probe token * unprotected (12 h); anything else, a 200 without the token (some other page) included, unknown (5 min). */ public static function probe(): ?bool { self::ensure(); $root = self::root_path(); $token = self::random_suffix( 24 ); $file = 'probe-' . self::random_suffix( 8 ) . '.txt'; file_put_contents( $root . '/' . $file, $token ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- one tiny probe file in the plugin's own storage root, written and removed within this request; WP_Filesystem may need credentials here. $res = wp_remote_get( content_url( basename( $root ) . '/' . $file ), [ 'timeout' => 5, 'sslverify' => false ] ); wp_delete_file( $root . '/' . $file ); $code = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res ); if ( 200 === $code && false !== strpos( (string) wp_remote_retrieve_body( $res ), $token ) ) { set_transient( self::PROBE_TRANSIENT, '0', 12 * HOUR_IN_SECONDS ); return false; } if ( in_array( $code, [ 401, 403, 404 ], true ) ) { set_transient( self::PROBE_TRANSIENT, '1', 12 * HOUR_IN_SECONDS ); return true; } set_transient( self::PROBE_TRANSIENT, 'u', 5 * MINUTE_IN_SECONDS ); return null; } /** The nginx rule that denies the storage root, built from content_url()'s path (WP_CONTENT_DIR may be moved). */ public static function nginx_rule(): string { $path = untrailingslashit( (string) wp_parse_url( content_url(), PHP_URL_PATH ) ); return 'location ~* ' . addcslashes( $path, '.\\+*?[^]$(){}=!<>|:' ) . '/mench-migrate-[a-z0-9]+/ { deny all; }'; } private static function root_path(): string { return trailingslashit( WP_CONTENT_DIR ) . (string) get_option( self::OPTION, '' ); } private static function write_guard( string $path, string $content, bool $refresh = false ): void { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reads a local guard file (index.php, .htaccess, web.config) of a few bytes in the plugin's own storage root. if ( ! is_file( $path ) || ( $refresh && (string) file_get_contents( $path ) !== $content ) ) { file_put_contents( $path, $content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- writes that small guard file; it must exist before any backup lands, even where WP_Filesystem would ask for FTP credentials. } } }