plugin = $plugin; } /** * Keys that are selected by default: design, layouts and content. * * @param array $changes Changeset. * @return string[] */ public static function default_keys( array $changes ): array { $keys = array(); foreach ( $changes as $key => $change ) { // Deletions are never selected by default. if ( ! in_array( $change['status'], array( 'deleted', 'trashed' ), true ) && in_array( $change['class'], array( Classifier::DESIGN, Classifier::LAYOUT, Classifier::CONTENT ), true ) ) { $keys[] = $key; } } return $keys; } /** * Builds a bundle file for a selection (plus dependencies). * * @param string[]|null $keys Selected keys, null = default selection. * @return array{file: string, keys: string[], added: array, summary: array} */ public function build( ?array $keys = null ): array { if ( ! Changeset::meta() ) { throw new \RuntimeException( esc_html__( 'There is no baseline yet. Record it right after cloning.', 'wonderful-sync' ) ); } $changeset = $this->plugin->changeset(); $changes = $changeset->compute(); if ( null !== $keys ) { $unknown = array_diff( $keys, array_keys( $changes ) ); if ( $unknown ) { /* translators: %s: list of keys. */ throw new \RuntimeException( esc_html( sprintf( __( 'These keys are no longer in the changeset: %s', 'wonderful-sync' ), implode( ', ', $unknown ) ) ) ); } } $keys = null === $keys ? self::default_keys( $changes ) : array_values( $keys ); if ( empty( $keys ) ) { throw new \RuntimeException( esc_html__( 'Nothing selected.', 'wonderful-sync' ) ); } $deps = $changeset->with_dependencies( $keys, $changes ); $file = Storage::scratch() . Storage::new_id() . '.zip'; return array( 'file' => $file, 'keys' => $deps['keys'], 'added' => $deps['added'], 'summary' => Bundle::build( $this->plugin->exporter(), $changes, $deps['keys'], $file ), ); } /** * Builds, uploads and plans on the target. * * @param string[]|null $keys Selection. * @return array{remote_id: string, keys: string[], added: array, plan: array, hashes: array} */ public function preview( ?array $keys = null ): array { $remote = $this->remote(); $this->reconcile_rollbacks(); $built = $this->build( $keys ); try { $remote_id = $remote->upload( $built['file'] ); $plan = $remote->plan( $remote_id ); } finally { wp_delete_file( $built['file'] ); } $hashes = array(); foreach ( $built['summary']['objects'] as $object ) { $hashes[ $object['key'] ] = 'delete' === $object['op'] ? '' : (string) $object['hash']; } return array( 'remote_id' => $remote_id, 'keys' => $built['keys'], 'added' => $built['added'], 'plan' => $plan, 'hashes' => $hashes, ); } /** * Applies a previewed bundle on the target and moves the baseline. * * @param string $remote_id Bundle ID on the target. * @param array $decisions Conflict decisions. * @param bool $force Ignore blockers. * @param array $hashes Key => bundle hash from preview(). * @return array Release. */ public function apply_remote( string $remote_id, array $decisions, bool $force, array $hashes, string $digest = '' ): array { $release = $this->remote()->apply( $remote_id, $decisions, $force, $digest ); $applied = array_intersect_key( $hashes, array_flip( (array) ( $release['applied'] ?? array() ) ) ); // Remember the state before, so a rollback on the live site brings the changes back. $this->remember_transfer( (string) ( $release['id'] ?? '' ), $this->plugin->changeset()->baseline_rows( array_keys( $applied ) ) ); $this->plugin->changeset()->mark_synced( $applied ); return $release; } /** * Option with the baseline rows each transfer replaced. */ const OPTION_TRANSFERS = 'wonderful_sync_transfers'; /** * Stores the baseline rows a transfer replaced (last 50 transfers). * * @param string $id Transfer ID on the live site. * @param array $rows Baseline rows before the transfer. */ private function remember_transfer( string $id, array $rows ): void { if ( '' === $id || ! $rows ) { return; } $list = (array) get_option( self::OPTION_TRANSFERS, array() ); $list[ $id ] = $rows; update_option( self::OPTION_TRANSFERS, array_slice( $list, -50, null, true ), false ); } /** * Transfers rolled back on the live site: their changes count as changes on the staging * copy again, so they can be transferred once more. * * @return int Number of objects that became changes again. */ public function reconcile_rollbacks(): int { $list = (array) get_option( self::OPTION_TRANSFERS, array() ); if ( ! $list || ! Remote::from_settings() ) { return 0; } $count = 0; foreach ( (array) $this->remote()->releases() as $release ) { $id = (string) ( $release['id'] ?? '' ); if ( isset( $list[ $id ] ) && in_array( (string) ( $release['status'] ?? '' ), array( 'rolled_back', 'rollback_incomplete' ), true ) ) { $this->plugin->changeset()->restore_rows( (array) $list[ $id ] ); $count += count( (array) $list[ $id ] ); unset( $list[ $id ] ); } } update_option( self::OPTION_TRANSFERS, $list, false ); return $count; } // ------------------------------------------------------------------ // Live → staging. // ------------------------------------------------------------------ /** * Option with the baseline rows each "live → staging" transfer replaced, by transfer ID * on this staging copy: a rollback here puts them back. */ const OPTION_PULLS = 'wonderful_sync_pulls'; /** * Staging side: what changed on the live site since the baseline. * * @return array Changes in the shape of Changeset::compute(). */ public function pull_changes(): array { if ( ! Changeset::meta() ) { throw new \RuntimeException( esc_html__( 'There is no baseline yet. Record it right after cloning.', 'wonderful-sync' ) ); } // Transfers rolled back on the live site first become staging changes again; else // their old state would show up here as a change on the live site. $this->reconcile_rollbacks(); $changeset = $this->plugin->changeset(); $answer = $this->remote()->pull_changes( $changeset->baseline_hashes() ); return $changeset->compare_remote( (array) ( $answer['rows'] ?? array() ), array_map( 'strval', (array) ( $answer['missing'] ?? array() ) ) ); } /** * Staging side: has the live site build a package of the selection, downloads it and * plans it on this site. * * @param string[] $keys Selected keys. * @return array{file: string, digest: string, keys: string[], added: array, plan: array, hashes: array} */ public function pull_preview( array $keys ): array { $changes = $this->pull_changes(); $unknown = array_diff( $keys, array_keys( $changes ) ); if ( $unknown ) { /* translators: %s: list of keys. */ throw new \RuntimeException( esc_html( sprintf( __( 'These keys are no longer in the changeset: %s', 'wonderful-sync' ), implode( ', ', $unknown ) ) ) ); } if ( empty( $keys ) ) { throw new \RuntimeException( esc_html__( 'Nothing selected.', 'wonderful-sync' ) ); } $changeset = $this->plugin->changeset(); $baseline = $changeset->baseline(); $gone = array(); foreach ( $keys as $key ) { if ( 'deleted' === $changes[ $key ]['status'] && isset( $baseline[ $key ] ) ) { $gone[ $key ] = array_intersect_key( $baseline[ $key ], array_flip( array( 'k', 'c', 't', 's' ) ) ); } } $remote = $this->remote(); $package = $remote->pull_package( array_values( $keys ), $changeset->baseline_hashes(), $gone, (int) Changeset::meta()['created'] ); $file = Storage::dir( 'incoming' ) . Storage::new_id() . '.zip'; $remote->download( (string) ( $package['id'] ?? '' ), (int) ( $package['size'] ?? 0 ), $file ); $digest = (string) hash_file( 'sha256', $file ); if ( ! hash_equals( (string) ( $package['sha256'] ?? '' ), $digest ) ) { wp_delete_file( $file ); throw new \RuntimeException( esc_html__( 'The package from the live site arrived incomplete. Please start again.', 'wonderful-sync' ) ); } $manifest = Bundle::read_manifest( $file ); $hashes = array(); foreach ( (array) $manifest['objects'] as $object ) { $hashes[ (string) $object['key'] ] = (string) $object['hash']; } return array( 'file' => $file, 'digest' => $digest, 'keys' => array_keys( $hashes ), 'added' => (array) ( $package['added'] ?? array() ), 'plan' => $this->plugin->importer()->plan( $manifest, $file ), 'hashes' => $hashes, ); } /** * Staging side: applies a downloaded live package and moves the baseline, so the taken * over objects count as equal on both sites. * * @param string $file Package file. * @param array $decisions Conflict decisions. * @param bool $force Ignore soft blockers. * @param array $hashes Key => hash from the package. * @return array Release on this site. */ public function apply_pull( string $file, array $decisions, bool $force, array $hashes ): array { $release = $this->plugin->importer()->apply( $file, $decisions, $force ); $applied = array_intersect_key( $hashes, array_flip( (array) ( $release['applied'] ?? array() ) ) ); $changeset = $this->plugin->changeset(); $id = (string) ( $release['id'] ?? '' ); if ( '' !== $id && $applied ) { $list = (array) get_option( self::OPTION_PULLS, array() ); $list[ $id ] = $changeset->baseline_rows( array_keys( $applied ) ); update_option( self::OPTION_PULLS, array_slice( $list, -50, null, true ), false ); } $changeset->mark_synced( $applied ); return $release; } /** * Staging side: after a "live → staging" transfer was rolled back here, its objects * differ from the live site again; the baseline gets its rows back. * * @param string $id Transfer ID on this site. */ public function after_local_rollback( string $id ): void { $list = (array) get_option( self::OPTION_PULLS, array() ); if ( isset( $list[ $id ] ) && Changeset::meta() ) { $this->plugin->changeset()->restore_rows( (array) $list[ $id ] ); unset( $list[ $id ] ); update_option( self::OPTION_PULLS, $list, false ); } } /** * Live side: objects that differ from the baseline hashes of the staging copy. * * @param array $hashes Baseline hashes. * @return array{rows: array, missing: string[]} */ public function serve_changes( array $hashes ): array { self::assert_live(); return $this->plugin->changeset()->remote_rows( $hashes ); } /** * Live side: builds a package of the selection for the staging copy. * * @param string[] $keys Selected keys. * @param array $hashes Baseline hashes of the staging copy. * @param array $gone Baseline rows (k, c, t, s) of selected deletions. * @param int $created When the staging copy was cloned. * @return array{id: string, size: int, sha256: string, added: array} */ public function serve_package( array $keys, array $hashes, array $gone, int $created ): array { self::assert_live(); if ( ! Storage::is_private() ) { throw new \RuntimeException( esc_html( Importer::storage_message() ) ); } $changeset = $this->plugin->changeset(); $remote = $changeset->remote_rows( $hashes ); $baseline = array(); foreach ( $hashes as $key => $hash ) { $baseline[ $key ] = array( 'h' => (string) $hash ) + ( isset( $gone[ $key ] ) && is_array( $gone[ $key ] ) ? array_map( 'strval', $gone[ $key ] ) : array() ); } $changes = $changeset->compare_remote( $remote['rows'], $remote['missing'], $baseline ); $unknown = array_diff( $keys, array_keys( $changes ) ); if ( $unknown || ! $keys ) { /* translators: %s: list of keys. */ throw new \RuntimeException( esc_html( sprintf( __( 'These keys are no longer in the changeset: %s', 'wonderful-sync' ), implode( ', ', $unknown ) ) ) ); } // Old packages go; at most a few wait for their download at a time. $dir = Storage::dir( 'pull' ); foreach ( (array) glob( $dir . '*.zip' ) as $old ) { if ( time() - (int) filemtime( $old ) > HOUR_IN_SECONDS ) { wp_delete_file( $old ); } } if ( count( (array) glob( $dir . '*.zip' ) ) >= 5 ) { throw new \RuntimeException( esc_html__( 'Too many unfinished uploads on this site. Try again later.', 'wonderful-sync' ) ); } $deps = $changeset->with_dependencies( array_values( $keys ), $changes ); $id = Storage::new_id(); $file = $dir . $id . '.zip'; Bundle::build( $this->plugin->exporter(), $changes, $deps['keys'], $file, array( 'direction' => 'pull', 'origin' => home_url(), 'baseline' => $created, ) ); return array( 'id' => $id, 'size' => (int) filesize( $file ), 'sha256' => (string) hash_file( 'sha256', $file ), 'added' => $deps['added'], ); } /** * Live side: one part of a package for the staging copy. * * @param string $id Package ID. * @param int $offset Byte offset. * @return array{data: string, size: int} */ public function serve_chunk( string $id, int $offset ): array { self::assert_live(); $file = Storage::dir( 'pull' ) . $id . '.zip'; if ( ! Storage::valid_id( $id ) || ! is_file( $file ) || $offset < 0 ) { throw new \RuntimeException( esc_html__( 'Unknown package.', 'wonderful-sync' ) ); } $handle = fopen( $file, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- chunked download. fseek( $handle, $offset ); $data = (string) fread( $handle, Remote::CHUNK_SIZE ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose $size = (int) filesize( $file ); if ( $offset + strlen( $data ) >= $size ) { wp_delete_file( $file ); } return array( 'data' => base64_encode( $data ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- binary in JSON. 'size' => $size, ); } /** * Only the live site hands out its changes, never a staging copy. */ private static function assert_live(): void { if ( Changeset::meta() ) { throw new \RuntimeException( esc_html__( 'This site is a staging copy. Changes are taken over from the live site only.', 'wonderful-sync' ) ); } } /** * Plans a bundle file on this site (target, file mode). * * @param string $file Bundle ZIP. * @return array */ public function plan_local( string $file ): array { return $this->plugin->importer()->plan( Bundle::read_manifest( $file ), $file ); } /** * Applies a bundle file on this site (target, file mode). * * @param string $file Bundle ZIP. * @param array $decisions Conflict decisions. * @param bool $force Ignore blockers. * @return array Release. */ public function apply_local( string $file, array $decisions, bool $force = false ): array { return $this->plugin->importer()->apply( $file, $decisions, $force ); } /** * Configured remote. */ public function remote(): Remote { $remote = Remote::from_settings(); if ( ! $remote ) { throw new \RuntimeException( esc_html__( 'No live site is connected. Enter it in the settings.', 'wonderful-sync' ) ); } return $remote; } }