/ so the * directory name cannot be guessed, and every sub directory gets an index.php and a * deny-all .htaccess for servers that honour it. */ class Storage { const OPTION_DIR = 'wonderful_sync_dir'; /** * Absolute path of the working directory, with trailing slash. */ public static function base(): string { // A directory outside the web root is the safest place; nginx ignores .htaccess. // Each site of a network gets its own folder below it. if ( self::external_dir() ) { $dir = trailingslashit( self::external_dir() ) . 'site-' . self::site_key() . '/'; self::protect( $dir ); return $dir; } $name = (string) get_option( self::OPTION_DIR, '' ); if ( '' === $name || ! preg_match( '/^wonderful-sync-[a-z0-9]{12}$/', $name ) ) { $name = 'wonderful-sync-' . strtolower( wp_generate_password( 12, false, false ) ); update_option( self::OPTION_DIR, $name, false ); } $uploads = wp_upload_dir( null, false ); $dir = trailingslashit( $uploads['basedir'] ) . $name . '/'; self::protect( $dir ); return $dir; } /** * Absolute path of a sub directory, created on demand. * * @param string $sub Relative path like "releases/abc". */ public static function dir( string $sub ): string { $sub = trim( str_replace( '..', '', $sub ), '/' ); $dir = self::base() . $sub . '/'; self::protect( $dir ); return $dir; } /** * Writes JSON atomically. * * @param string $file Absolute path. * @param mixed $data Data. */ public static function write_json( string $file, $data ): bool { // Nothing lands in the working directory while it may be served on the web. if ( 0 === strpos( $file, self::base() ) && ! self::is_private() ) { return false; } $tmp = $file . '.tmp'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- local working files, WP_Filesystem may need credentials. if ( false === file_put_contents( $tmp, wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ) ) { return false; } return rename( $tmp, $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- atomic replace. } /** * Reads JSON, or returns null. * * @param string $file Absolute path. * @return mixed|null */ public static function read_json( string $file ) { if ( ! is_readable( $file ) ) { return null; } $raw = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file. return false === $raw ? null : json_decode( $raw, true ); } /** * Removes a directory recursively, only inside the working directory. * * @param string $dir Absolute path. */ public static function remove_dir( string $dir ): void { $base = self::base(); if ( 0 !== strpos( $dir, $base ) || ! is_dir( $dir ) ) { return; } $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ( $items as $item ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- own working directory. $item->isDir() ? rmdir( $item->getPathname() ) : wp_delete_file( $item->getPathname() ); } rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- own working directory. } /** * Random identifier for bundles and releases. */ public static function new_id(): string { return gmdate( 'Ymd-His' ) . '-' . strtolower( wp_generate_password( 6, false, false ) ); } /** * Whether an identifier is safe to use in a path. * * @param string $id Identifier. */ public static function valid_id( string $id ): bool { return (bool) preg_match( '/^[0-9]{8}-[0-9]{6}-[a-z0-9]{6}$/', $id ); } /** * Whether files in the working directory can be downloaded from the web. * * Writes a probe file and requests it over HTTP. true = public, false = protected, * null = the check itself failed (unknown). The result is cached for a day. */ public static function is_public(): ?bool { if ( self::$checked ) { return self::$result; } self::$checked = true; self::$result = self::probe(); return self::$result; } /** * Why the last check could not tell whether the directory is public ('' when it could). */ public static function check_error(): string { self::is_public(); return self::$error; } /** * Forgets the result of this request, e.g. after the configuration changed. */ public static function reset_check(): void { self::$checked = false; self::$result = null; self::$error = ''; delete_transient( 'wonderful_sync_storage_public' ); } /** * Whether the check ran in this request. * * @var bool */ private static $checked = false; /** * Result of the check in this request. * * @var bool|null */ private static $result = null; /** * Why the check failed. * * @var string */ private static $error = ''; /** * Runs the check (see is_public()). */ private static function probe(): ?bool { self::$error = ''; if ( self::external_dir() ) { return false; } $cached = get_transient( 'wonderful_sync_storage_public' ); if ( in_array( $cached, array( 'yes', 'no' ), true ) ) { return 'yes' === $cached; } $token = wp_generate_password( 20, false, false ); $file = self::base() . 'probe.txt'; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- probe file. file_put_contents( $file, $token ); $uploads = wp_upload_dir( null, false ); $url = trailingslashit( $uploads['baseurl'] ) . basename( untrailingslashit( self::base() ) ) . '/probe.txt'; $response = wp_remote_get( $url, array( 'timeout' => 5, 'redirection' => 3, 'sslverify' => false, ) ); wp_delete_file( $file ); if ( is_wp_error( $response ) ) { self::$error = $url . ': ' . $response->get_error_message(); return null; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 === $code && false !== strpos( (string) wp_remote_retrieve_body( $response ), $token ) ) { $public = true; } elseif ( in_array( $code, array( 401, 403, 404, 410 ), true ) || 200 === $code ) { $public = false; } else { // Server error or odd answer: unknown, ask again next time. self::$error = $url . ': HTTP ' . $code; return null; } set_transient( 'wonderful_sync_storage_public', $public ? 'yes' : 'no', DAY_IN_SECONDS ); return $public; } /** * Whether the working directory is private: outside the web root (constant) or verified * to refuse web requests. Everything that writes snapshots or bundles checks this first. */ public static function is_private(): bool { return false === self::is_public(); } /** * The configured directory outside the web root, or '' when not set or not usable. * * A directory inside ABSPATH or uploads does not count: it may be reachable from the web. */ public static function external_dir(): string { if ( ! defined( 'WONDERFUL_SYNC_STORAGE_DIR' ) || '' === (string) WONDERFUL_SYNC_STORAGE_DIR ) { return ''; } $dir = untrailingslashit( (string) WONDERFUL_SYNC_STORAGE_DIR ); if ( ! is_dir( $dir ) ) { wp_mkdir_p( $dir ); } $real = realpath( $dir ); if ( false === $real || ! wp_is_writable( $real ) || self::inside_web_root( $real ) ) { return ''; } return $real; } /** * Whether a path lies inside the WordPress directory or the web server's document root. * * The document root is remembered from web requests, so WP-CLI can check it too. * * @param string $path Real path. */ public static function inside_web_root( string $path ): bool { $uploads = wp_upload_dir( null, false ); $roots = array( realpath( ABSPATH ), realpath( $uploads['basedir'] ), realpath( dirname( WONDERFUL_SYNC_PLUGIN_DIR, 2 ) ) ); $doc = isset( $_SERVER['DOCUMENT_ROOT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['DOCUMENT_ROOT'] ) ) : ''; if ( '' !== $doc && false !== realpath( $doc ) ) { $roots[] = realpath( $doc ); if ( get_option( 'wonderful_sync_docroot' ) !== realpath( $doc ) ) { update_option( 'wonderful_sync_docroot', realpath( $doc ), false ); } } elseif ( get_option( 'wonderful_sync_docroot' ) ) { $roots[] = (string) get_option( 'wonderful_sync_docroot' ); } foreach ( array_filter( $roots ) as $root ) { if ( $path === $root || 0 === strpos( $path . '/', rtrim( $root, '/' ) . '/' ) ) { return true; } } return false; } /** * Stable key of this site within a shared storage directory (installation and network site). */ public static function site_key(): string { global $wpdb; return get_current_blog_id() . '-' . substr( md5( DB_NAME . '|' . $wpdb->prefix ), 0, 10 ); } /** * A private place for temporary files: the working directory when it is private, else * the system temp directory when it lies outside the web root. */ public static function scratch(): string { if ( self::is_private() ) { return self::dir( 'tmp' ); } $tmp = realpath( sys_get_temp_dir() ); if ( false !== $tmp && wp_is_writable( $tmp ) && ! self::inside_web_root( $tmp ) ) { $dir = trailingslashit( $tmp ) . 'wonderful-sync-' . self::site_key() . '/'; wp_mkdir_p( $dir ); return $dir; } throw new \RuntimeException( esc_html__( 'There is no private place for the package file. Define WONDERFUL_SYNC_STORAGE_DIR in wp-config.php with a writable directory outside the web root.', 'wonderful-sync' ) ); } /** * The former folder under uploads, when the working directory now lies outside the web * root (WONDERFUL_SYNC_STORAGE_DIR) and files are still there; '' otherwise. */ public static function legacy_dir(): string { $name = (string) get_option( self::OPTION_DIR, '' ); if ( ! self::external_dir() || ! preg_match( '/^wonderful-sync-[a-z0-9]{12}$/', $name ) ) { return ''; } $uploads = wp_upload_dir( null, false ); $dir = trailingslashit( $uploads['basedir'] ) . $name . '/'; $files = is_dir( $dir ) ? array_diff( (array) scandir( $dir ), array( '.', '..', 'index.php', '.htaccess', 'probe.txt' ) ) : array(); return $files ? $dir : ''; } /** * Moves baseline, file signatures and releases from the former folder under uploads into * the working directory outside the web root. Files that already exist there are kept. * The former folder is removed once every file arrived unchanged. * * @return array{moved: int, kept: int} Moved files, and files kept because they existed. * @throws \RuntimeException When a file cannot be copied. */ public static function move_legacy(): array { $from = self::legacy_dir(); $to = self::base(); $out = array( 'moved' => 0, 'kept' => 0, ); if ( '' === $from ) { return $out; } $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $from, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::SELF_FIRST ); foreach ( $items as $item ) { $rel = substr( $item->getPathname(), strlen( $from ) ); if ( $item->isLink() ) { continue; } if ( $item->isDir() ) { self::protect( $to . $rel . '/' ); continue; } if ( in_array( basename( $rel ), array( 'index.php', '.htaccess', 'probe.txt' ), true ) ) { continue; } if ( file_exists( $to . $rel ) ) { ++$out['kept']; continue; } if ( ! copy( $item->getPathname(), $to . $rel ) || md5_file( $item->getPathname() ) !== md5_file( $to . $rel ) ) { /* translators: %s: file name. */ throw new \RuntimeException( esc_html( sprintf( __( 'Could not copy %s. The former folder was left as it is.', 'wonderful-sync' ), $rel ) ) ); } ++$out['moved']; } $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $from, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ( $items as $item ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- own former working directory. $item->isDir() && ! $item->isLink() ? rmdir( $item->getPathname() ) : wp_delete_file( $item->getPathname() ); } rmdir( $from ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- own former working directory. return $out; } /** * Creates the directory with index.php and .htaccess. * * @param string $dir Absolute path with trailing slash. */ private static function protect( string $dir ): void { if ( ! is_dir( $dir ) ) { wp_mkdir_p( $dir ); } if ( ! file_exists( $dir . 'index.php' ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- directory guard. file_put_contents( $dir . 'index.php', "