/ 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.
}
}
}