url = self::canonical_url( $url ); $this->user = $user; $this->password = $password; } /** * Client from the saved settings, or null. */ public static function from_settings(): ?Remote { $s = self::settings(); if ( '' === $s['url'] || '' === $s['user'] || '' === $s['password'] ) { return null; } return new self( $s['url'], $s['user'], $s['password'] ); } /** * Saved settings with the password decrypted. * * @return array{url: string, user: string, password: string} */ public static function settings(): array { $raw = get_option( self::OPTION, array() ); $raw = is_array( $raw ) ? $raw : array(); return array( 'url' => (string) ( $raw['url'] ?? '' ), 'user' => (string) ( $raw['user'] ?? '' ), 'password' => self::decrypt( (string) ( $raw['password'] ?? '' ) ), ); } /** * Saves settings. An empty password keeps the stored one. * * @param string $url URL. * @param string $user User. * @param string $password Application password. */ public static function save( string $url, string $user, string $password ): void { $old = get_option( self::OPTION, array() ); $old = is_array( $old ) ? $old : array(); $url = self::canonical_url( $url ); if ( '' === $url ) { throw new \InvalidArgumentException( esc_html__( 'Enter the address of the live site, like https://example.com.', 'wonderful-sync' ) ); } update_option( self::OPTION, array( 'url' => $url, 'user' => sanitize_user( $user ), // A new address or user never inherits the old password. 'password' => '' !== $password ? self::encrypt( preg_replace( '/\s+/', '', $password ) ) : ( self::same_host( (string) ( $old['url'] ?? '' ), $url ) && sanitize_user( $user ) === (string) ( $old['user'] ?? '' ) ? (string) ( $old['password'] ?? '' ) : '' ), ), false ); } /** * Normalised base URL: scheme, host, optional port and path; no user, query or fragment. * * @param string $url URL. * @return string '' when not usable. */ public static function canonical_url( string $url ): string { $parts = wp_parse_url( trim( $url ) ); if ( ! is_array( $parts ) || empty( $parts['host'] ) || ! in_array( strtolower( $parts['scheme'] ?? '' ), array( 'http', 'https' ), true ) || isset( $parts['user'] ) || isset( $parts['pass'] ) || isset( $parts['query'] ) || isset( $parts['fragment'] ) ) { return ''; } $host = strtolower( (string) $parts['host'] ); if ( ! preg_match( '/^[a-z0-9.-]+$|^\[[0-9a-f:]+\]$/', $host ) ) { return ''; } return strtolower( $parts['scheme'] ) . '://' . $host . ( isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '' ) . untrailingslashit( (string) ( $parts['path'] ?? '' ) ); } /** * Whether two URLs point to the same host and port. * * @param string $a URL. * @param string $b URL. */ private static function same_host( string $a, string $b ): bool { $part = function ( $url ) { return strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ) . ':' . (string) wp_parse_url( $url, PHP_URL_PORT ) . untrailingslashit( (string) wp_parse_url( $url, PHP_URL_PATH ) ); }; return '' !== $a && $part( $a ) === $part( $b ); } /** * Whether plain HTTP without certificate checks is allowed: only when both this site * and the target run on local development hosts (two Docker clones, for example). * * @param string $url URL. */ public static function is_local( string $url ): bool { // Local targets (two Docker clones) need an explicit opt-in in wp-config.php. return defined( 'WONDERFUL_SYNC_ALLOW_LOCAL_TARGETS' ) && WONDERFUL_SYNC_ALLOW_LOCAL_TARGETS && self::is_local_host( $url ) && self::is_local_host( home_url() ); } /** * Whether a URL points to a local development host. * * @param string $url URL. */ private static function is_local_host( string $url ): bool { $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); return in_array( $host, array( 'localhost', '127.0.0.1', '::1', 'host.docker.internal' ), true ) || (bool) preg_match( '/\.(test|localhost)$/', $host ); } /** * Target site information. * * @return array */ public function info(): array { return $this->request( 'GET', '/info' ); } /** * Uploads a bundle in chunks and returns its ID on the target. * * @param string $file Bundle ZIP. */ public function upload( string $file ): string { $id = (string) ( $this->request( 'POST', '/bundles' )['id'] ?? '' ); $handle = fopen( $file, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- chunked upload. if ( '' === $id || ! $handle ) { throw new \RuntimeException( esc_html__( 'The upload could not be started.', 'wonderful-sync' ) ); } $offset = 0; while ( ! feof( $handle ) ) { $chunk = (string) fread( $handle, self::CHUNK_SIZE ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread if ( '' === $chunk ) { break; } $tries = 0; do { try { $answer = $this->request( 'POST', '/bundles/' . $id . '/chunk&offset=' . $offset, $chunk, 'application/octet-stream' ); if ( (int) ( $answer['size'] ?? -1 ) !== $offset + strlen( $chunk ) ) { throw new \RuntimeException( esc_html__( 'The live site received a different amount of data. Please start again.', 'wonderful-sync' ) ); } break; } catch ( \RuntimeException $e ) { if ( ++$tries >= 3 ) { fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose throw $e; } } } while ( true ); $offset += strlen( $chunk ); } fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return $id; } /** * Plan of an uploaded bundle. * * @param string $id Bundle ID on the target. * @return array */ public function plan( string $id ): array { return $this->request( 'POST', '/bundles/' . $id . '/plan' ); } /** * Field differences of one object between the live site and an uploaded bundle. * * @param string $id Bundle ID on the target. * @param string $key Object key. * @return array */ public function diff( string $id, string $key ): array { return $this->request( 'GET', '/bundles/' . $id . '/diff&key=' . rawurlencode( $key ) ); } /** * Applies an uploaded bundle. * * @param string $id Bundle ID. * @param array $decisions Conflict decisions. * @param bool $force Ignore soft blockers. * @param string $digest Digest from plan(): the live site applies only exactly that bundle. * @return array Release. */ public function apply( string $id, array $decisions, bool $force = false, string $digest = '' ): array { try { return $this->request( 'POST', '/bundles/' . $id . '/apply', (string) wp_json_encode( array( 'decisions' => (object) $decisions, 'force' => $force, 'digest' => $digest, ) ) ); } catch ( \RuntimeException $e ) { // A proxy may cut the request while the live site keeps applying. Ask for the result. if ( ! preg_match( '/timed out|timeout|cURL error 28|^50[234] |^52[0-9] /i', $e->getMessage() ) ) { throw $e; } return $this->wait_for_release( $id ); } } /** * Polls the release list until the release of a bundle is finished (up to 30 minutes). * * @param string $bundle_id Bundle ID. * @return array Release in the shape apply() returns. */ private function wait_for_release( string $bundle_id ): array { $deadline = time() + 30 * MINUTE_IN_SECONDS; while ( time() < $deadline ) { sleep( 5 ); try { $list = $this->releases(); } catch ( \RuntimeException $e ) { continue; } foreach ( $list as $row ) { if ( ( $row['bundle'] ?? '' ) !== $bundle_id || 'applying' === ( $row['status'] ?? '' ) ) { continue; } if ( 'applied' !== $row['status'] ) { /* translators: %s: release ID. */ throw new \RuntimeException( esc_html( sprintf( __( 'Transfer %s failed on the live site. See its transfer log.', 'wonderful-sync' ), $row['id'] ) ) ); } $record = $this->request( 'GET', '/releases/' . $row['id'] ); return array( 'id' => (string) $row['id'], 'applied' => array_keys( (array) ( $record['entries'] ?? array() ) ), 'warnings' => (array) ( $record['warnings'] ?? array() ), ); } } throw new \RuntimeException( esc_html__( 'The live site did not report a result in time. Check the transfer log on the live site.', 'wonderful-sync' ) ); } /** * Releases on the target. * * @return array */ public function releases(): array { return $this->request( 'GET', '/releases' ); } /** * Live → staging: objects that differ on the live site from the baseline hashes. * * @param array $hashes Baseline hashes. * @return array{rows: array, missing: string[]} */ public function pull_changes( array $hashes ): array { return $this->request( 'POST', '/pull/changes', (string) wp_json_encode( array( 'hashes' => (object) $hashes ) ) ); } /** * Live → staging: has the live site build a package of the selection. * * @param string[] $keys Selection. * @param array $hashes Baseline hashes. * @param array $gone Baseline rows of selected deletions. * @param int $created Clone time. * @return array{id: string, size: int, sha256: string, added: array} */ public function pull_package( array $keys, array $hashes, array $gone, int $created ): array { return $this->request( 'POST', '/pull/packages', (string) wp_json_encode( array( 'keys' => $keys, 'hashes' => (object) $hashes, 'gone' => (object) $gone, 'created' => $created, ) ) ); } /** * Live → staging: downloads a package in parts. * * @param string $id Package ID on the live site. * @param int $size Expected size. * @param string $file Local file. */ public function download( string $id, int $size, string $file ): void { if ( ! Storage::valid_id( $id ) || $size <= 0 ) { throw new \RuntimeException( esc_html__( 'Unknown package.', 'wonderful-sync' ) ); } $handle = fopen( $file, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- chunked download. if ( ! $handle ) { throw new \RuntimeException( esc_html__( 'The package file could not be written.', 'wonderful-sync' ) ); } $offset = 0; try { while ( $offset < $size ) { $answer = $this->request( 'GET', '/pull/packages/' . $id . '&offset=' . $offset ); $data = base64_decode( (string) ( $answer['data'] ?? '' ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- binary in JSON. if ( false === $data || '' === $data || (int) ( $answer['size'] ?? 0 ) !== $size ) { throw new \RuntimeException( esc_html__( 'The package from the live site arrived incomplete. Please start again.', 'wonderful-sync' ) ); } fwrite( $handle, $data ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite $offset += strlen( $data ); } } finally { fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } } /** * Rolls a release back on the target. * * @param string $id Release ID. * @param bool $force Also restore objects changed since. * @return array */ public function rollback( string $id, bool $force = false ): array { return $this->request( 'POST', '/releases/' . $id . '/rollback', (string) wp_json_encode( array( 'force' => $force ) ) ); } /** * Sends a request to the target REST API. * * @param string $method HTTP method. * @param string $route Route below the namespace (query parameters appended with &). * @param string $body Body. * @param string $type Content type. * @return array */ private function request( string $method, string $route, string $body = '', string $type = 'application/json' ): array { $local = self::is_local( $this->url ); if ( 0 !== strpos( $this->url, 'https://' ) && ! $local ) { throw new \RuntimeException( esc_html__( 'The live site must be reached over HTTPS.', 'wonderful-sync' ) ); } $url = $this->url . '/?rest_route=/' . Rest::NS . $route; $args = array( 'method' => $method, /** * Filters how long the staging site waits for one answer of the live site, in seconds. * After a timeout of an apply, it asks the live site for the result instead. * * @param int $seconds Default 300. */ 'timeout' => (int) apply_filters( 'wonderful_sync_http_timeout', 300 ), 'redirection' => 0, 'sslverify' => ! $local, 'headers' => array( 'Authorization' => 'Basic ' . base64_encode( $this->user . ':' . $this->password ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic authentication. 'Content-Type' => $type, 'Accept' => 'application/json', ), 'body' => '' !== $body ? $body : null, ); // Public targets go through wp_safe_remote_request(), which refuses internal addresses. $response = $local ? wp_remote_request( $url, $args ) : wp_safe_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { $message = $response->get_error_message(); $host = strtolower( (string) wp_parse_url( $this->url, PHP_URL_HOST ) ); if ( in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true ) ) { // In Docker, localhost is the container itself, not the computer it runs on. $port = wp_parse_url( $this->url, PHP_URL_PORT ); $message .= ' ' . sprintf( /* translators: %s: suggested URL. */ __( 'If this site runs in a Docker container, “localhost” means the container itself. Enter the live site as %s instead.', 'wonderful-sync' ), // Docker's name for the host machine (written in parts: it is no external service). sprintf( 'http://%s.%s.%s', 'host', 'docker', 'internal' ) . ( $port ? ':' . $port : '' ) ); } throw new \RuntimeException( esc_html( $message ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( $code >= 300 ) { $message = is_array( $data ) && isset( $data['message'] ) ? (string) $data['message'] : (string) wp_remote_retrieve_response_message( $response ); if ( 401 === $code && 0 !== strpos( $this->url, 'https://' ) ) { $message .= ' ' . __( 'Application passwords only work over HTTPS, or on sites with WP_ENVIRONMENT_TYPE set to "local".', 'wonderful-sync' ); } if ( $code >= 300 && $code < 400 ) { /* translators: %s: redirect target. */ $message = sprintf( __( 'The live site redirects to %s. Enter that address instead.', 'wonderful-sync' ), (string) wp_remote_retrieve_header( $response, 'location' ) ); } throw new \RuntimeException( esc_html( sprintf( '%d %s', $code, $message ) ) ); } return is_array( $data ) ? $data : array(); } /** * Encrypts a secret with a key derived from this site's salts. * * @param string $plain Secret. */ private static function encrypt( string $plain ): string { if ( '' === $plain ) { return ''; } $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); return base64_encode( $nonce . sodium_crypto_secretbox( $plain, $nonce, self::key() ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- binary storage. } /** * Decrypts a stored secret. * * @param string $stored Stored value. */ private static function decrypt( string $stored ): string { $raw = base64_decode( $stored, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- binary storage. if ( false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) { return ''; } $plain = sodium_crypto_secretbox_open( substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ), self::key() ); return false === $plain ? '' : $plain; } /** * Encryption key. */ private static function key(): string { return sodium_crypto_generichash( wp_salt( 'auth' ) . 'wonderful-sync', '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES ); } }