'wonderful-sync', 'pull' => 'wonderful-sync-pull', 'releases' => 'wonderful-sync-releases', 'inventory' => 'wonderful-sync-inventory', 'connection' => 'wonderful-sync-connection', ); /** * Constructor. * * @param Wonderful_Sync_Plugin $plugin Plugin. */ public function __construct( Wonderful_Sync_Plugin $plugin ) { $this->plugin = $plugin; } /** * Registers hooks. */ public function register(): void { add_action( 'admin_menu', array( $this, 'add_page' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_filter( 'plugin_action_links_' . plugin_basename( WONDERFUL_SYNC_PLUGIN_FILE ), array( $this, 'action_link' ) ); add_action( 'wp_ajax_wonderful_sync_diff', array( $this, 'ajax_diff' ) ); foreach ( array( 'baseline', 'origin', 'remote', 'refresh', 'preview', 'apply', 'download', 'upload', 'rollback', 'deployer', 'clear_log', 'role', 'pull_refresh', 'pull_preview', 'pods' ) as $action ) { add_action( 'admin_post_wonderful_sync_' . $action, array( $this, 'handle_' . $action ) ); } // Any content change marks the cached changeset as outdated. // Uploads and media edits do not fire save_post. foreach ( array( 'add_attachment', 'edit_attachment', 'delete_attachment', 'save_post', 'deleted_post', 'edited_term', 'created_term', 'delete_term', 'wp_update_nav_menu', 'added_post_meta', 'updated_post_meta', 'deleted_post_meta', 'added_term_meta', 'updated_term_meta', 'deleted_term_meta' ) as $hook ) { add_action( $hook, array( __CLASS__, 'mark_dirty' ) ); } foreach ( array( 'added_option', 'deleted_option' ) as $hook ) { add_action( $hook, function ( $name ) { if ( 0 !== strpos( (string) $name, '_transient' ) && 0 !== strpos( (string) $name, '_site_transient' ) && 0 !== strpos( (string) $name, 'wonderful_sync' ) ) { self::mark_dirty(); } } ); } add_action( 'updated_option', function ( $name ) { if ( 0 !== strpos( (string) $name, '_transient' ) && 0 !== strpos( (string) $name, '_site_transient' ) && 0 !== strpos( (string) $name, 'wonderful_sync' ) ) { self::mark_dirty(); } } ); } /** * Marks the cached changeset as outdated (once per request). */ public static function mark_dirty(): void { static $done = false; if ( ! $done ) { $done = true; update_option( self::DIRTY, time(), false ); } } /** * Adds the Wonderful Sync menu, below the core menus. */ public function add_page(): void { $this->page_hooks[] = (string) add_menu_page( __( 'Wonderful Sync', 'wonderful-sync' ), __( 'Wonderful Sync', 'wonderful-sync' ), 'manage_options', self::PAGES['changes'], array( $this, 'render' ), 'dashicons-update' ); $role = self::role(); $first = array( 'staging' => __( 'Staging → Live', 'wonderful-sync' ), 'live' => __( 'Transfer log', 'wonderful-sync' ), '' => __( 'Set up', 'wonderful-sync' ), ); $labels = array( 'changes' => $first[ $role ], 'pull' => __( 'Live → Staging', 'wonderful-sync' ), 'releases' => __( 'Transfer log', 'wonderful-sync' ), 'inventory' => __( 'Inventory', 'wonderful-sync' ), 'connection' => __( 'Settings', 'wonderful-sync' ), ); foreach ( self::PAGES as $section => $slug ) { // The transfer log is the start page on live; on a staging copy it lists what was // taken over from live. "Live → Staging" exists only on a staging copy. $hidden = ( 'releases' === $section && 'staging' !== $role ) || ( 'pull' === $section && 'staging' !== $role ) || ( 'inventory' === $section && '' === $role ); $parent = $hidden ? '' : self::PAGES['changes']; $this->page_hooks[] = (string) add_submenu_page( $parent, $labels[ $section ] . ' – ' . __( 'Wonderful Sync', 'wonderful-sync' ), $labels[ $section ], 'manage_options', $slug, array( $this, 'render' ) ); } } /** * What this site is in the sync: 'staging' (has a baseline), 'live', or '' (not set up). */ public static function role(): string { if ( Changeset::meta() ) { return 'staging'; } $chosen = (string) get_option( self::OPTION_ROLE, '' ); if ( 'live' === $chosen || Importer::releases() || get_users( array( 'role' => Wonderful_Sync_Plugin::ROLE, 'number' => 1, 'fields' => 'ID' ) ) ) { return 'live'; } return ''; } /** * Stylesheet and the selection script, on our page only. * * @param string $hook_suffix Current page. */ public function enqueue( $hook_suffix ): void { if ( ! in_array( $hook_suffix, $this->page_hooks, true ) ) { return; } // File time in the version: browsers fetch the new files right after an update. $dir = plugin_dir_path( WONDERFUL_SYNC_PLUGIN_FILE ); wp_enqueue_style( self::STYLE_HANDLE, plugins_url( 'assets/css/admin.css', WONDERFUL_SYNC_PLUGIN_FILE ), array(), WONDERFUL_SYNC_VERSION . '.' . (int) filemtime( $dir . 'assets/css/admin.css' ) ); wp_enqueue_script( self::STYLE_HANDLE, plugins_url( 'assets/js/admin.js', WONDERFUL_SYNC_PLUGIN_FILE ), array(), WONDERFUL_SYNC_VERSION . '.' . (int) filemtime( $dir . 'assets/js/admin.js' ), true ); wp_localize_script( self::STYLE_HANDLE, 'wonderfulSync', array( /* translators: 1: selected count, 2: total count. */ 'selected' => __( '%1$s of %2$s selected', 'wonderful-sync' ), 'ajax' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'wonderful_sync_diff' ), 'close' => __( 'Close', 'wonderful-sync' ), 'loading' => __( 'Loading differences …', 'wonderful-sync' ), 'confirm' => __( 'Replace the baseline? Changes made since the old baseline will no longer be detected.', 'wonderful-sync' ), ) ); } /** * Link on the plugins screen. * * @param array $links Links. */ public function action_link( array $links ): array { array_unshift( $links, sprintf( '%s', esc_url( self::url() ), esc_html__( 'Open', 'wonderful-sync' ) ) ); return $links; } /** * URL of a tab. * * @param string $tab Tab. * @param array $args Extra query args. */ public static function url( string $tab = '', array $args = array() ): string { // Steps of a live → staging transfer stay on its own page (and menu entry). $page = self::PAGES[ $tab ] ?? self::PAGES[ self::$flow ]; // Steps of a transfer (preview, diff, result) live on the main page. $args = array_merge( array( 'page' => $page ), '' !== $tab && ! isset( self::PAGES[ $tab ] ) ? array( 'tab' => $tab ) : array(), $args ); return add_query_arg( $args, admin_url( 'admin.php' ) ); } // ------------------------------------------------------------------ // Rendering. // ------------------------------------------------------------------ /** * Renders the page. */ public function render(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $titles = array( 'changes' => __( 'Wonderful Sync', 'wonderful-sync' ), 'pull' => __( 'Wonderful Sync: Live → Staging', 'wonderful-sync' ), 'releases' => __( 'Wonderful Sync: Transfer log', 'wonderful-sync' ), 'inventory' => __( 'Wonderful Sync: Inventory', 'wonderful-sync' ), 'connection' => __( 'Wonderful Sync: Settings', 'wonderful-sync' ), ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- navigation only. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : self::PAGES['changes']; $tab = (string) ( array_search( $page, self::PAGES, true ) ?: 'changes' ); if ( 'pull' === $tab ) { self::$flow = 'pull'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- navigation only. $step = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : ''; $tab = in_array( $step, array( 'preview', 'result', 'diff' ), true ) ? $step : 'pull'; } elseif ( 'changes' === $tab ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- navigation only. $step = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : ''; $tab = in_array( $step, array( 'preview', 'result', 'diff' ), true ) ? $step : 'changes'; // On the live site the start page is its transfer log. if ( 'changes' === $tab && 'live' === self::role() ) { $tab = 'releases'; } } $title = $titles[ $tab ] ?? $titles['changes']; ?>

render_role(); ?> render_notice(); switch ( $tab ) { case 'preview': $this->render_preview(); break; case 'diff': $this->render_diff(); break; case 'result': $this->render_result(); break; case 'releases': $this->render_releases(); break; case 'pull': $this->render_pull(); break; case 'inventory': $this->render_inventory(); break; case 'connection': $this->render_connection(); break; default: $this->render_changes(); } ?>
'; if ( 'staging' === $role ) { $origin = (string) Changeset::meta()['origin']; /* translators: %s: "staging" badge. */ printf( esc_html__( 'This site is %s', 'wonderful-sync' ), '' . esc_html__( 'Staging', 'wonderful-sync' ) . '' ); echo ' – '; if ( '' !== $origin ) { /* translators: %s: live URL (a link). */ printf( esc_html__( 'a copy of the live site %s', 'wonderful-sync' ), '' . esc_html( $origin ) . '' ); } else { printf( '%s', esc_url( self::url( 'connection' ) ), esc_html__( 'a copy of a live site (enter its address in the settings)', 'wonderful-sync' ) ); } } elseif ( 'live' === $role ) { $count = count( Importer::releases() ); /* translators: %s: "live" badge. */ printf( esc_html__( 'This site is the %s site', 'wonderful-sync' ), '' . esc_html__( 'Live', 'wonderful-sync' ) . '' ); echo ' – ' . esc_html( sprintf( /* translators: %s: number of transfers. */ _n( 'it receives transfers from its staging copy, checks them and can roll each one back (%s transfer so far).', 'it receives transfers from its staging copy, checks them and can roll each one back (%s transfers so far).', $count, 'wonderful-sync' ), number_format_i18n( $count ) ) ); } else { esc_html_e( 'This site is not set up yet.', 'wonderful-sync' ); } echo '

'; } /** * Steps of a transfer, with the current one highlighted. * * @param int $current 1 changes, 2 preview, 3 result. */ private static function render_steps( int $current ): void { $steps = array( 1 => __( 'Choose changes', 'wonderful-sync' ), 2 => __( 'Check what will be transferred', 'wonderful-sync' ), 3 => __( 'Done', 'wonderful-sync' ), ); echo '
    '; foreach ( $steps as $n => $label ) { $class = $n < $current ? 'is-done' : ( $n === $current ? 'is-current' : '' ); echo '
  1. ' . esc_html( (string) $n ) . ' ' . esc_html( $label ) . '
  2. '; } echo '
'; } /** * Small preview of an image, so new and changed media are visible in the lists. * * @param string $key Object key. * @param string $subtype Post type. */ private function media_thumb( string $key, string $subtype ): void { if ( 'attachment' !== $subtype || 0 !== strpos( $key, 'post:' ) ) { return; } $uid = substr( $key, 5 ); $id = 0 === strpos( $uid, 'b:' ) ? (int) substr( $uid, 2 ) : $this->plugin->exporter()->find_post( $uid ); if ( $id && wp_attachment_is_image( $id ) ) { // Escaped by WordPress. echo wp_get_attachment_image( $id, array( 48, 48 ), false, array( 'class' => 'wonderful-sync-thumb', 'alt' => '' ) ); } } /** * Readable type of an object. * * @param string $kind post, term or option. * @param string $subtype Post type or taxonomy. */ private static function type_label( string $kind, string $subtype ): string { $names = array( 'et_pb_layout' => __( 'Divi Library item', 'wonderful-sync' ), 'et_template' => __( 'Theme Builder template', 'wonderful-sync' ), 'et_header_layout' => __( 'Theme Builder header', 'wonderful-sync' ), 'et_body_layout' => __( 'Theme Builder body', 'wonderful-sync' ), 'et_footer_layout' => __( 'Theme Builder footer', 'wonderful-sync' ), 'et_theme_builder' => __( 'Theme Builder', 'wonderful-sync' ), 'nav_menu_item' => __( 'Menu item', 'wonderful-sync' ), 'nav_menu' => __( 'Menu', 'wonderful-sync' ), 'attachment' => __( 'Media', 'wonderful-sync' ), '_pods_pod' => __( 'Pod', 'wonderful-sync' ), '_pods_group' => __( 'Pods field group', 'wonderful-sync' ), '_pods_field' => __( 'Pods field', 'wonderful-sync' ), ); if ( 'option' === $kind ) { return __( 'Setting', 'wonderful-sync' ); } if ( isset( $names[ $subtype ] ) ) { return $names[ $subtype ]; } $object = 'term' === $kind ? get_taxonomy( $subtype ) : get_post_type_object( $subtype ); if ( $object && ! empty( $object->labels->singular_name ) ) { return (string) $object->labels->singular_name; } return '' !== $subtype ? $subtype : $kind; } /** * Why an object is a conflict, seen from the site that receives the package. * * @param string $code Conflict code. * @param bool $pull Live → staging. */ private static function conflict_label( string $code, bool $pull ): string { if ( ! $pull ) { return self::label( $code ); } $labels = array( 'changed_on_target' => __( 'changed on the staging copy since the clone', 'wonderful-sync' ), 'deleted_on_target' => __( 'deleted on the staging copy since the clone', 'wonderful-sync' ), 'exists_on_target' => __( 'already exists on the staging copy', 'wonderful-sync' ), ); return $labels[ $code ] ?? self::label( $code ); } /** * Shows and clears the flash notice. */ private function render_notice(): void { $key = self::NOTICE . get_current_user_id(); $notice = get_transient( $key ); if ( ! is_array( $notice ) ) { return; } delete_transient( $key ); printf( '', esc_attr( $notice['type'] ), esc_html( $notice['message'] ) ); } /** * Translated labels for internal values. * * @param string $value Internal value. */ private static function label( string $value ): string { $labels = array( 'new' => __( 'new', 'wonderful-sync' ), 'changed' => __( 'changed', 'wonderful-sync' ), 'deleted' => __( 'deleted', 'wonderful-sync' ), 'trashed' => __( 'moved to trash', 'wonderful-sync' ), 'create' => __( 'create', 'wonderful-sync' ), 'update' => __( 'update', 'wonderful-sync' ), 'delete' => __( 'move to trash', 'wonderful-sync' ), 'skip' => __( 'skip', 'wonderful-sync' ), 'refused' => __( 'refused', 'wonderful-sync' ), 'applying' => __( 'running or interrupted', 'wonderful-sync' ), 'rolling_back' => __( 'rolling back or interrupted', 'wonderful-sync' ), 'applied' => __( 'transferred', 'wonderful-sync' ), 'failed' => __( 'failed', 'wonderful-sync' ), 'rolled_back' => __( 'rolled back', 'wonderful-sync' ), 'rollback_incomplete' => __( 'rolled back, with problems', 'wonderful-sync' ), 'missing' => __( 'not installed on live', 'wonderful-sync' ), 'inactive' => __( 'inactive on live', 'wonderful-sync' ), 'version' => __( 'other version on live', 'wonderful-sync' ), 'changed_on_target' => __( 'changed on live since the clone', 'wonderful-sync' ), 'deleted_on_target' => __( 'deleted on live since the clone', 'wonderful-sync' ), 'exists_on_target' => __( 'already exists on live', 'wonderful-sync' ), ); $classes = Classifier::labels(); return $labels[ $value ] ?? ( $classes[ $value ] ?? $value ); } /** * Field chips: which parts of an object differ. * * @param string[] $fields Field labels. */ private static function field_chips( array $fields ): void { if ( empty( $fields ) ) { return; } $shown = array_slice( $fields, 0, 6 ); echo ''; foreach ( $shown as $field ) { echo '' . esc_html( Exporter::field_name( (string) $field ) ) . ''; } if ( count( $fields ) > count( $shown ) ) { /* translators: %s: number of further fields. */ echo '' . esc_html( sprintf( __( '+%s more', 'wonderful-sync' ), number_format_i18n( count( $fields ) - count( $shown ) ) ) ) . ''; } echo ''; } /** * Changeset, cached until something changes or the user asks for a fresh check. * * @return array{changes: array, checked: int} */ private function cached_changes(): array { $key = self::CHANGES . get_current_blog_id(); $cached = get_transient( $key ); $dirty = (int) get_option( self::DIRTY, 0 ); if ( is_array( $cached ) && $cached['checked'] > $dirty ) { return $cached; } $this->reconcile( false ); $result = array( 'changes' => $this->plugin->changeset()->compute(), 'checked' => time(), ); set_transient( $key, $result, 10 * MINUTE_IN_SECONDS ); return $result; } /** * Changes since the baseline, with selection. */ private function render_changes(): void { $meta = Changeset::meta(); if ( ! $meta ) { $this->render_first_steps(); return; } try { $cached = $this->cached_changes(); } catch ( \RuntimeException $e ) { echo '

' . esc_html( $e->getMessage() ) . '

'; return; } $changes = $cached['changes']; self::storage_notice(); ?>

render_change_list( $changes, 'push' ); ?> render_first_steps(); return; } if ( ! Remote::from_settings() ) { echo '

' . esc_html__( 'Connect the live site first.', 'wonderful-sync' ) . ' ' . esc_html__( 'Connect live site', 'wonderful-sync' ) . '

'; return; } $key = self::PULL . get_current_user_id(); $cached = get_transient( $key ); if ( ! is_array( $cached ) ) { try { $cached = array( 'changes' => $this->plugin->sync()->pull_changes(), 'checked' => time(), ); set_transient( $key, $cached, 5 * MINUTE_IN_SECONDS ); } catch ( \Throwable $e ) { echo '

' . esc_html( $e->getMessage() ) . '

'; return; } } ?>


render_change_list( (array) $cached['changes'], 'pull' ); } /** * Selectable list of changes, grouped by class. Used in both directions. * * @param array $changes Changes (key => status, class, kind, subtype, title, fields). * @param string $direction 'push' (staging → live) or 'pull' (live → staging). */ private function render_change_list( array $changes, string $direction ): void { $pull = 'pull' === $direction; $selected = array_fill_keys( Sync::default_keys( $changes ), true ); $labels = Classifier::labels(); $groups = array(); $counts = array( 'new' => 0, 'changed' => 0, 'trashed' => 0, 'deleted' => 0 ); foreach ( $changes as $key => $change ) { $groups[ $change['class'] ][ $key ] = $change; ++$counts[ $change['status'] ]; } ?>

$label ) : ?>
()
$change ) : ?>
> __( 'New on the live site. It will be created on the staging copy.', 'wonderful-sync' ), 'deleted' => __( 'Deleted on the live site. It is removed on the staging copy only if selected; menus and categories are never removed.', 'wonderful-sync' ), 'trashed' => __( 'Moved to the trash on the live site. It is moved to the trash on the staging copy only if selected.', 'wonderful-sync' ), ) : array( 'new' => __( 'New on the staging copy. It will be created on the live site.', 'wonderful-sync' ), 'deleted' => __( 'Deleted on the staging copy. It is removed on the live site only if selected; menus and categories are never removed.', 'wonderful-sync' ), 'trashed' => __( 'Moved to the trash on the staging copy. It is moved to the trash on the live site only if selected.', 'wonderful-sync' ), ); if ( isset( $notes[ $change['status'] ] ) ) { echo '' . esc_html( $notes[ $change['status'] ] ) . ''; } else { self::field_chips( (array) ( $change['fields'] ?? array() ) ); } ?>

render_role_choice( self::role() ); } /** * "What is this site?" – two cards, the current role marked, the other one selectable. * * @param string $current Current role. */ private function render_role_choice( string $current ): void { $meta = Changeset::meta(); ?>

✓

✓

baseline_form( '', false ); ?>
$id ) : null; } /** * Plan from the target, with conflict decisions. */ private function render_preview(): void { $job = $this->current_job(); if ( ! $job || empty( $job['plan'] ) ) { echo '

' . esc_html__( 'This preview has expired or has already been transferred.', 'wonderful-sync' ) . ' ' . esc_html__( 'Back to the changes', 'wonderful-sync' ) . '

'; return; } self::render_steps( 2 ); $pull = 'pull' === ( $job['mode'] ?? '' ); $plan = $job['plan']; $hard = (array) ( $plan['hard'] ?? array() ); $soft = array_values( array_diff( (array) $plan['blockers'], $hard ) ); $counts = array_count_values( wp_list_pluck( $plan['items'], 'action' ) ); $conflict = count( array_filter( wp_list_pluck( $plan['items'], 'conflict' ) ) ); ?>

array(), 'change' => array(), 'other' => array(), ); foreach ( $plan['items'] as $item ) { if ( '' !== $item['conflict'] ) { $groups['conflict'][] = $item; } elseif ( in_array( $item['action'], array( 'create', 'update', 'delete' ), true ) ) { $groups['change'][] = $item; } else { $groups['other'][] = $item; } } ?>

1 ) : ?>

render_plan_table( $job, $groups['conflict'], true ); ?>

render_plan_table( $job, $groups['change'], false ); ?>
render_plan_table( $job, $groups['other'], false ); ?>

media_thumb( (string) $item['key'], (string) $item['subtype'] ); ?>


current_job(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view. $key = isset( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : ''; if ( ! $job || '' === $key ) { echo '

' . esc_html__( 'This preview has expired.', 'wonderful-sync' ) . '

'; return; } try { if ( 'remote' === $job['mode'] ) { $diff = $this->plugin->sync()->remote()->diff( (string) $job['remote_id'], $key ); } else { $diff = $this->plugin->importer()->diff( Bundle::read_manifest( (string) $job['file'] ), (string) $job['file'], $key ); } } catch ( \Throwable $e ) { echo '

' . esc_html( $e->getMessage() ) . '

'; return; } if ( ! $embed ) { echo '

← ' . esc_html__( 'Back to the preview', 'wonderful-sync' ) . '

'; } $pull = 'pull' === ( $job['mode'] ?? '' ); $now = $pull ? __( 'Staging copy now', 'wonderful-sync' ) : __( 'Live site now', 'wonderful-sync' ); $item = array( 'title' => $key, 'kind' => '', 'subtype' => '', ); foreach ( (array) ( $job['plan']['items'] ?? array() ) as $candidate ) { if ( $candidate['key'] === $key ) { $item = $candidate; } } ?>

$now, 'title_right' => __( 'After the transfer', 'wonderful-sync' ), ); foreach ( $diff as $label => $pair ) { $old = (string) ( $pair['live'] ?? '' ); $new = (string) ( $pair['bundle'] ?? '' ); // wp_text_diff() escapes the compared texts itself. $code = wp_text_diff( $old, $new, $titles ); $text = self::has_markup( $old . $new ) ? wp_text_diff( self::readable( $old ), self::readable( $new ), $titles ) : ''; echo '
'; echo '

' . esc_html( Exporter::field_name( (string) $label ) ) . '

'; if ( '' === $code ) { echo '

' . esc_html__( 'Only whitespace differs.', 'wonderful-sync' ) . '

'; } elseif ( '' !== $text ) { echo '
' . wp_kses_post( $text ) . '
'; echo '
' . esc_html__( 'Show the code', 'wonderful-sync' ) . '
' . wp_kses_post( $code ) . '
'; } else { if ( self::has_markup( $old . $new ) ) { echo '

' . esc_html__( 'The visible text is the same. Only layout or settings differ in the code.', 'wonderful-sync' ) . '

'; } echo '
' . wp_kses_post( $code ) . '
'; } echo '
'; } } /** * Whether a value holds HTML, blocks or shortcodes. * * @param string $value Value. */ private static function has_markup( string $value ): bool { return (bool) preg_match( '/<[a-z!\/]|\[\/?[a-z_]+[\s\]]/i', $value ); } /** * Visible text of page content: one line per paragraph, heading or list item; blocks, * shortcodes and tags removed. Only for the comparison view. * * @param string $value Content. */ private static function readable( string $value ): string { // Divi 5 and Gutenberg keep visible text in the block attributes (innerContent). $value = (string) preg_replace_callback( '//s', static function ( $m ) { $attrs = json_decode( $m[1], true ); $texts = array(); $walk = static function ( $node ) use ( &$walk, &$texts ) { if ( ! is_array( $node ) ) { return; } foreach ( $node as $k => $v ) { if ( 'innerContent' === $k && is_array( $v ) && isset( $v['desktop']['value'] ) ) { $val = $v['desktop']['value']; if ( is_string( $val ) ) { $texts[] = $val; } elseif ( is_array( $val ) ) { foreach ( array( 'text', 'title', 'alt' ) as $field ) { if ( isset( $val[ $field ] ) && is_string( $val[ $field ] ) ) { $texts[] = $val[ $field ]; } } } } else { $walk( $v ); } } }; $walk( $attrs ); return "\n" . implode( "\n", $texts ) . "\n"; }, $value ); $value = (string) preg_replace( '//s', '', $value ); $value = (string) preg_replace( '/\[\/?[a-z0-9_]+(?:\s[^\]]*)?\]/i', "\n", $value ); $value = (string) preg_replace( '#<(br|/p|/h[1-6]|/li|/div|/tr|/blockquote)\b[^>]*>#i', "\n", $value ); $value = html_entity_decode( wp_strip_all_tags( $value ), ENT_QUOTES, 'UTF-8' ); $lines = array_filter( array_map( 'trim', preg_split( '/\R/', $value ) ), 'strlen' ); return implode( "\n", $lines ); } /** * Result of an apply. */ private function render_result(): void { $job = $this->current_job(); if ( ! $job || empty( $job['release'] ) ) { echo '

' . esc_html__( 'Nothing to show.', 'wonderful-sync' ) . '

'; return; } self::render_steps( 3 ); $pull = 'pull' === ( $job['mode'] ?? '' ); $release = $job['release']; $applied = array_flip( (array) $release['applied'] ); $kept = array(); $refused = array(); $skipped = array(); foreach ( (array) ( $job['plan']['items'] ?? array() ) as $item ) { if ( isset( $applied[ $item['key'] ] ) ) { continue; } if ( 'refused' === $item['action'] ) { $refused[] = $item; } elseif ( '' !== $item['conflict'] ) { $kept[] = $item; } elseif ( 'skip' !== $item['action'] ) { $skipped[] = $item; } } echo '

'; $done_text = $pull /* translators: 1: number of objects, 2: transfer ID. */ ? _n( '%1$s object taken over from the live site (transfer %2$s). It can be rolled back in the transfer log of this site.', '%1$s objects taken over from the live site (transfer %2$s). It can be rolled back in the transfer log of this site.', count( $applied ), 'wonderful-sync' ) /* translators: 1: number of objects, 2: transfer ID. */ : _n( '%1$s object transferred (transfer %2$s). It can be rolled back in the transfer log of the live site.', '%1$s objects transferred (transfer %2$s). It can be rolled back in the transfer log of the live site.', count( $applied ), 'wonderful-sync' ); echo esc_html( sprintf( $done_text, number_format_i18n( count( $applied ) ), $release['id'] ) ); echo '

'; foreach ( (array) ( $release['warnings'] ?? array() ) as $msg ) { echo '

' . esc_html( $msg ) . '

'; } $cards = array( 'create' => array( count( $applied ), $pull ? __( 'taken over', 'wonderful-sync' ) : __( 'transferred', 'wonderful-sync' ) ), 'conflict' => array( count( $kept ), $pull ? __( 'kept on the staging copy', 'wonderful-sync' ) : __( 'kept on the live site', 'wonderful-sync' ) ), 'refused' => array( count( $refused ), __( 'refused', 'wonderful-sync' ) ), 'skip' => array( count( $skipped ), __( 'left out', 'wonderful-sync' ) ), ); echo '
'; foreach ( $cards as $class => $card ) { echo '
' . esc_html( number_format_i18n( $card[0] ) ) . '' . esc_html( $card[1] ) . '
'; } echo '
'; $done = array(); foreach ( (array) ( $job['plan']['items'] ?? array() ) as $item ) { if ( isset( $applied[ $item['key'] ] ) ) { $done[] = $item; } } if ( $done ) { echo '

' . esc_html__( 'Transferred', 'wonderful-sync' ) . '

'; } $lists = array( ( $pull ? __( 'Kept on the staging copy (conflicts)', 'wonderful-sync' ) : __( 'Kept on the live site (conflicts)', 'wonderful-sync' ) ) => $kept, ( $pull ? __( 'Refused by this site', 'wonderful-sync' ) : __( 'Refused by the live site', 'wonderful-sync' ) ) => $refused, __( 'Left out because a required object was not transferred', 'wonderful-sync' ) => $skipped, ); foreach ( $lists as $title => $items ) { if ( empty( $items ) ) { continue; } echo '

' . esc_html( $title ) . '

'; } if ( $pull ) { echo '

' . esc_html__( 'Open the transfer log', 'wonderful-sync' ) . ' ' . esc_html__( 'Back to Live → Staging', 'wonderful-sync' ) . '

'; } if ( 'remote' === ( $job['mode'] ?? '' ) ) { // The live site's own address (the connection may use an internal one). $live = (string) ( $job['plan']['target']['home'] ?? ( Changeset::meta()['origin'] ?? Remote::settings()['url'] ) ); echo '

' . esc_html__( 'Open the transfer log on the live site', 'wonderful-sync' ) . '

'; } } /** * Releases on this site, the deploy user and bundle upload. */ private function render_releases(): void { $list = Importer::releases(); self::storage_notice(); ?>



$entry ) : ?>

render_upload(); ?>

Settings > Connection to the live site. The password is shown only once.', 'wonderful-sync' ); ?>

Wonderful_Sync_Plugin::ROLE, 'fields' => array( 'user_login' ), ) ); ?>

plugin->classifier() ) )->build(); $labels = Classifier::labels(); ?>

$label ) : ?>
$label ) : ?>
> ()
render_first_steps(); return; } if ( 'live' === $role ) { ?>

render_deployer(); ?> render_integrations(); ?> render_role_choice( 'live' ); ?> info(); } catch ( \Throwable $e ) { $error = $e->getMessage(); } } ?>

Settings). Then enter the details here.', 'wonderful-sync' ); ?>

baseline_form( (string) $meta['origin'], true ); ?>
baseline_form( $settings['url'], false ); ?> render_integrations(); ?> render_role_choice( 'staging' ); ?>

' . esc_html( Importer::storage_message() ) . '

'; } elseif ( '' !== Storage::legacy_dir() ) { /* translators: %s: WP-CLI command. */ echo '

' . esc_html( sprintf( __( 'Snapshots are now stored outside the web root, but files are still in the former folder under uploads, where the web server may serve them. Move them with: %s', 'wonderful-sync' ), 'wp wsync storage --move' ) ) . '

'; } } /** * Form that records the baseline. * * @param string $origin Prefilled origin URL. * @param bool $replace Whether a baseline exists and must be confirmed. */ private function baseline_form( string $origin, bool $replace ): void { $private = Storage::is_private(); if ( ! $private ) { self::storage_notice(); } ?>
guard( 'wonderful_sync_baseline' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked in guard(). $origin = isset( $_POST['origin'] ) ? esc_url_raw( wp_unslash( $_POST['origin'] ) ) : ''; $replace = ! empty( $_POST['replace'] ); // phpcs:enable if ( Changeset::meta() && ! $replace ) { $this->done( 'connection', 'error', __( 'A baseline exists. Confirm that you want to replace it.', 'wonderful-sync' ) ); } try { $meta = $this->plugin->changeset()->capture( $origin ); } catch ( \RuntimeException $e ) { $this->done( 'connection', 'error', $e->getMessage() ); } delete_option( self::OPTION_ROLE ); // A baseline makes this site the staging copy. self::mark_dirty(); /* translators: %s: number of objects. */ $this->done( 'changes', 'success', sprintf( __( 'Baseline recorded: %s objects.', 'wonderful-sync' ), number_format_i18n( $meta['count'] ) ) ); } /** * Updates the live URL of the baseline without recording a new one. */ public function handle_origin(): void { $this->guard( 'wonderful_sync_origin' ); $origin = isset( $_POST['origin'] ) ? esc_url_raw( wp_unslash( $_POST['origin'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked in guard(). Changeset::set_origin( $origin ); $this->done( 'connection', 'success', __( 'Live URL saved. The baseline is unchanged.', 'wonderful-sync' ) ); } /** * Saves the connection. */ public function handle_remote(): void { $this->guard( 'wonderful_sync_remote' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked in guard(). $url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : ''; $user = isset( $_POST['user'] ) ? sanitize_user( wp_unslash( $_POST['user'] ) ) : ''; $pass = isset( $_POST['password'] ) ? sanitize_text_field( wp_unslash( $_POST['password'] ) ) : ''; // phpcs:enable try { Remote::save( $url, $user, $pass ); } catch ( \InvalidArgumentException $e ) { $this->done( 'connection', 'error', $e->getMessage() ); } if ( Changeset::meta() && empty( Changeset::meta()['origin'] ) ) { Changeset::set_origin( $url ); } wp_safe_redirect( self::url( 'connection', array( 'test' => 1 ) ) ); exit; } /** * The comparison view for the modal on the preview page. */ public function ajax_diff(): void { check_ajax_referer( 'wonderful_sync_diff', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( null, 403 ); } ob_start(); $this->render_diff( true ); wp_send_json_success( array( 'html' => (string) ob_get_clean() ) ); } /** * Sets this site up as the live site. */ public function handle_role(): void { $this->guard( 'wonderful_sync_role' ); if ( Changeset::meta() ) { // A staging copy becomes the live site: its baseline goes. if ( empty( $_POST['confirm'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked in guard(). $this->done( 'connection', 'error', __( 'Confirm that the baseline of this staging copy may be discarded.', 'wonderful-sync' ) ); } delete_option( Changeset::OPTION_BASELINE ); wp_delete_file( Storage::base() . 'baseline.json' ); delete_transient( self::CHANGES . get_current_blog_id() ); } update_option( self::OPTION_ROLE, 'live', false ); $this->done( 'connection', 'success', __( 'This site is now set up as the live site. Create access for the staging copy next.', 'wonderful-sync' ) ); } /** * Asks the live site which transfers were rolled back (at most every 5 minutes unless * forced) and brings their changes back into the list. * * @param bool $force Check now. * @return int Objects that became changes again. */ private function reconcile( bool $force ): int { if ( ! $force && get_transient( 'wonderful_sync_reconciled' ) ) { return 0; } set_transient( 'wonderful_sync_reconciled', 1, 5 * MINUTE_IN_SECONDS ); try { return $this->plugin->sync()->reconcile_rollbacks(); } catch ( \Throwable $e ) { return 0; // The live site is not reachable right now; try again later. } } /** * Saves the Pods switch. */ public function handle_pods(): void { $this->guard( 'wonderful_sync_pods' ); update_option( Pods::OPTION, empty( $_POST['structure'] ) ? '0' : '1', false ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked in guard(). delete_transient( self::CHANGES . get_current_blog_id() ); $this->done( 'connection', 'success', __( 'Settings saved.', 'wonderful-sync' ) ); } /** * Clears the activity log. */ public function handle_clear_log(): void { $this->guard( 'wonderful_sync_clear_log' ); Tracker::clear(); $this->done( 'releases', 'success', __( 'The log of recent edits was cleared.', 'wonderful-sync' ) ); } /** * Forces a fresh changeset. */ public function handle_refresh(): void { $this->guard( 'wonderful_sync_refresh' ); delete_transient( self::CHANGES . get_current_blog_id() ); wp_delete_file( Storage::base() . 'file-signatures.json' ); $back = $this->reconcile( true ); if ( $back > 0 ) { /* translators: %s: number of objects. */ $this->done( 'changes', 'success', sprintf( _n( 'A transfer was rolled back on the live site: %s object is a change again.', 'A transfer was rolled back on the live site: %s objects are changes again.', $back, 'wonderful-sync' ), number_format_i18n( $back ) ) ); } wp_safe_redirect( self::url( 'changes' ) ); exit; } /** * Selected keys from the changes form: JSON (no max_input_vars limit) or checkboxes. * * @return string[] */ private static function posted_keys(): array { // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked in guard(). $json = isset( $_POST['keys_json'] ) ? json_decode( wp_unslash( $_POST['keys_json'] ), true ) : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- decoded and sanitized per item below. $keys = is_array( $json ) ? $json : ( isset( $_POST['keys'] ) ? (array) wp_unslash( $_POST['keys'] ) : array() ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per item below. // phpcs:enable return array_values( array_filter( array_map( 'sanitize_text_field', array_map( 'strval', $keys ) ) ) ); } /** * Builds the bundle and previews it on the target, or downloads it. */ public function handle_preview(): void { $this->guard( 'wonderful_sync_preview' ); $keys = self::posted_keys(); $mode = isset( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : 'remote'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked in guard(). if ( empty( $keys ) ) { $this->done( 'changes', 'error', __( 'Nothing selected.', 'wonderful-sync' ) ); } try { if ( 'download' === $mode ) { $built = $this->plugin->sync()->build( $keys ); $this->send_file( $built['file'], 'wonderful-sync-' . gmdate( 'Ymd-His' ) . '.zip' ); } $preview = $this->plugin->sync()->preview( $keys ); $id = $this->save_job( array( 'mode' => 'remote', 'target' => (string) Remote::settings()['url'], ) + $preview ); wp_safe_redirect( self::url( 'preview', array( 'job' => $id ) ) ); exit; } catch ( \Throwable $e ) { $this->done( 'changes', 'error', $e->getMessage() ); } } /** * Live → staging: asks the live site again. */ public function handle_pull_refresh(): void { $this->guard( 'wonderful_sync_pull_refresh' ); delete_transient( self::PULL . get_current_user_id() ); wp_safe_redirect( self::url( 'pull' ) ); exit; } /** * Live → staging: downloads the selection from the live site and checks it here. */ public function handle_pull_preview(): void { $this->guard( 'wonderful_sync_pull_preview' ); self::$flow = 'pull'; $keys = self::posted_keys(); if ( empty( $keys ) ) { $this->done( 'pull', 'error', __( 'Nothing selected.', 'wonderful-sync' ) ); } if ( ! Storage::is_private() ) { $this->done( 'pull', 'error', Importer::storage_message() ); } try { $preview = $this->plugin->sync()->pull_preview( $keys ); $id = $this->save_job( array( 'mode' => 'pull', 'target' => (string) Remote::settings()['url'], ) + $preview ); wp_safe_redirect( self::url( 'preview', array( 'job' => $id ) ) ); exit; } catch ( \Throwable $e ) { $this->done( 'pull', 'error', $e->getMessage() ); } } /** * Stores a preview job and returns its ID. * * @param array $job Job data. */ private function save_job( array $job ): string { $id = strtolower( wp_generate_password( 12, false, false ) ); set_transient( self::JOB . get_current_user_id() . '_' . $id, $job, HOUR_IN_SECONDS ); return $id; } /** * Applies the previewed bundle (remote or local) of exactly the job in the form. */ public function handle_apply(): void { $this->guard( 'wonderful_sync_apply' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked in guard(). $id = isset( $_POST['job'] ) ? sanitize_key( wp_unslash( $_POST['job'] ) ) : ''; $job = '' !== $id ? get_transient( self::JOB . get_current_user_id() . '_' . $id ) : false; if ( ! is_array( $job ) ) { $this->done( 'changes', 'error', __( 'This preview has expired or has already been transferred. Please start again.', 'wonderful-sync' ) ); } if ( 'pull' === ( $job['mode'] ?? '' ) ) { self::$flow = 'pull'; } $decisions = array(); $json = isset( $_POST['decisions_json'] ) ? json_decode( wp_unslash( $_POST['decisions_json'] ), true ) : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per item below. $posted = is_array( $json ) && $json ? $json : ( isset( $_POST['decisions'] ) ? (array) wp_unslash( $_POST['decisions'] ) : array() ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per item below. foreach ( $posted as $key => $value ) { $decisions[ sanitize_text_field( (string) $key ) ] = 'source' === $value ? 'source' : 'target'; } $force = ! empty( $_POST['force'] ); // phpcs:enable try { if ( 'remote' === $job['mode'] && (string) ( $job['target'] ?? '' ) !== (string) Remote::settings()['url'] ) { throw new \RuntimeException( esc_html__( 'The connection was changed after this preview. Please start again.', 'wonderful-sync' ) ); } if ( 'remote' === $job['mode'] ) { $release = $this->plugin->sync()->apply_remote( (string) $job['remote_id'], $decisions, $force, (array) ( $job['hashes'] ?? array() ), (string) ( $job['plan']['digest'] ?? '' ) ); } elseif ( 'pull' === $job['mode'] ) { $file = (string) $job['file']; if ( ! is_file( $file ) || ! hash_equals( (string) $job['digest'], (string) hash_file( 'sha256', $file ) ) ) { throw new \RuntimeException( esc_html__( 'This preview has expired or has already been transferred. Please start again.', 'wonderful-sync' ) ); } $release = $this->plugin->sync()->apply_pull( $file, $decisions, $force, (array) ( $job['hashes'] ?? array() ) ); Bundle::close( $file ); wp_delete_file( $file ); delete_transient( self::PULL . get_current_user_id() ); } else { $file = (string) $job['file']; if ( ! is_file( $file ) || ! hash_equals( (string) $job['digest'], (string) hash_file( 'sha256', $file ) ) ) { throw new \RuntimeException( esc_html__( 'The package differs from the previewed one. Please upload it again.', 'wonderful-sync' ) ); } $release = $this->plugin->sync()->apply_local( $file, $decisions, $force ); Bundle::close( $file ); wp_delete_file( $file ); } delete_transient( self::JOB . get_current_user_id() . '_' . $id ); delete_transient( self::CHANGES . get_current_blog_id() ); $result = $this->save_job( array( 'mode' => $job['mode'], 'release' => $release, 'plan' => $job['plan'], ) ); wp_safe_redirect( self::url( 'result', array( 'job' => $result ) ) ); exit; } catch ( \Throwable $e ) { set_transient( self::NOTICE . get_current_user_id(), array( 'type' => 'error', 'message' => $e->getMessage(), ), MINUTE_IN_SECONDS * 5 ); wp_safe_redirect( self::url( 'preview', array( 'job' => $id ) ) ); exit; } } /** * Receives an uploaded bundle file and plans it on this site. */ public function handle_upload(): void { $this->guard( 'wonderful_sync_upload' ); if ( empty( $_FILES['bundle'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked in guard(). $this->done( 'releases', 'error', __( 'No file received.', 'wonderful-sync' ) ); } if ( ! Storage::is_private() ) { $this->done( 'releases', 'error', Importer::storage_message() ); } if ( ! function_exists( 'wp_handle_upload' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } // Store the upload in the protected working directory, not in the public media folders. $dir = Storage::dir( 'incoming' ); $redirect = function ( $uploads ) use ( $dir ) { $uploads['path'] = untrailingslashit( $dir ); $uploads['subdir'] = ''; return $uploads; }; add_filter( 'upload_dir', $redirect ); $result = wp_handle_upload( $_FILES['bundle'], // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- checked in guard(); handled by wp_handle_upload(). array( 'test_form' => false, 'mimes' => array( 'zip' => 'application/zip' ), 'unique_filename_callback' => function () { return Storage::new_id() . '.zip'; }, ) ); remove_filter( 'upload_dir', $redirect ); if ( empty( $result['file'] ) ) { $this->done( 'releases', 'error', (string) ( $result['error'] ?? __( 'The file could not be saved.', 'wonderful-sync' ) ) ); } $file = (string) $result['file']; try { $digest = (string) hash_file( 'sha256', $file ); $plan = $this->plugin->sync()->plan_local( $file ); $id = $this->save_job( array( 'mode' => 'local', 'file' => $file, 'digest' => $digest, 'plan' => $plan, 'added' => array(), ) ); wp_safe_redirect( self::url( 'preview', array( 'job' => $id ) ) ); exit; } catch ( \Throwable $e ) { wp_delete_file( $file ); $this->done( 'releases', 'error', $e->getMessage() ); } } /** * Builds and sends a bundle with the default selection. */ public function handle_download(): void { $this->guard( 'wonderful_sync_download' ); try { $built = $this->plugin->sync()->build( null ); $this->send_file( $built['file'], 'wonderful-sync-' . gmdate( 'Ymd-His' ) . '.zip' ); } catch ( \Throwable $e ) { $this->done( 'changes', 'error', $e->getMessage() ); } } /** * Rolls a release back on this site. */ public function handle_rollback(): void { $this->guard( 'wonderful_sync_rollback' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- checked in guard(). $id = isset( $_POST['release'] ) ? sanitize_text_field( wp_unslash( $_POST['release'] ) ) : ''; $force = ! empty( $_POST['force'] ); // phpcs:enable try { $result = $this->plugin->importer()->rollback( $id, $force ); if ( ! empty( $result['restored'] ) ) { // A rolled back "live → staging" transfer differs from the live site again. $this->plugin->sync()->after_local_rollback( $id ); delete_transient( self::CHANGES . get_current_blog_id() ); delete_transient( self::PULL . get_current_user_id() ); } if ( ! empty( $result['drifted'] ) && empty( $result['restored'] ) ) { /* translators: %s: list of objects. */ $this->done( 'releases', 'warning', sprintf( __( 'Not rolled back: these objects were changed after the transfer: %s. Select "including objects changed since then" to restore them anyway.', 'wonderful-sync' ), implode( ', ', $result['drifted'] ) ) ); } /* translators: %s: number of objects. */ $message = sprintf( __( 'Rolled back: %s objects restored.', 'wonderful-sync' ), number_format_i18n( count( $result['restored'] ) ) ); $notes = (array) ( $result['warnings'] ?? array() ); $this->done( 'releases', empty( $notes ) && empty( $result['failed'] ) ? 'success' : 'warning', trim( $message . ' ' . implode( ' ', $notes ) ) ); } catch ( \Throwable $e ) { $this->done( 'releases', 'error', $e->getMessage() ); } } /** * Creates a deploy user with an application password and shows it once. */ public function handle_deployer(): void { $this->guard( 'wonderful_sync_deployer' ); // The deploy role can write unfiltered HTML: only someone who may create users and // write unfiltered HTML himself may hand it out (super admin on multisite). if ( ! current_user_can( 'create_users' ) || ! current_user_can( 'unfiltered_html' ) || ( is_multisite() && ! is_super_admin() ) ) { $this->done( 'connection', 'error', __( 'Only an administrator who may create users and use unfiltered HTML can create access for the staging copy.', 'wonderful-sync' ) ); } if ( ! wp_is_application_passwords_available() ) { $this->done( 'connection', 'error', __( 'Application passwords are not available on this site.', 'wonderful-sync' ) ); } if ( empty( $_POST['consent'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked in guard(). $this->done( 'connection', 'error', __( 'Confirm what the access allows before creating it.', 'wonderful-sync' ) ); } Wonderful_Sync_Plugin::ensure_role(); $login = 'wonderful-sync-' . strtolower( wp_generate_password( 6, false, false ) ); $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); $id = wp_insert_user( array( 'user_login' => $login, 'user_pass' => wp_generate_password( 32, true, true ), 'user_email' => $login . '@' . ( false !== strpos( $host, '.' ) ? $host : 'example.invalid' ), 'role' => Wonderful_Sync_Plugin::ROLE, ) ); if ( is_wp_error( $id ) ) { $this->done( 'connection', 'error', $id->get_error_message() ); } $created = \WP_Application_Passwords::create_new_application_password( (int) $id, array( 'name' => 'Wonderful Sync staging' ) ); if ( is_wp_error( $created ) ) { $this->done( 'connection', 'error', $created->get_error_message() ); } set_transient( self::SECRET . get_current_user_id(), array( 'user' => $login, 'password' => \WP_Application_Passwords::chunk_password( $created[0] ), ), 5 * MINUTE_IN_SECONDS ); wp_safe_redirect( self::url( 'connection' ) ); exit; } /** * Capability and nonce check. * * @param string $action Nonce action. */ private function guard( string $action ): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'wonderful-sync' ), '', array( 'response' => 403 ) ); } check_admin_referer( $action ); } /** * Sets a flash notice and redirects to a tab. * * @param string $tab Tab. * @param string $type success, error, warning. * @param string $message Message. */ private function done( string $tab, string $type, string $message ): void { set_transient( self::NOTICE . get_current_user_id(), array( 'type' => $type, 'message' => $message, ), MINUTE_IN_SECONDS * 5 ); wp_safe_redirect( self::url( $tab ) ); exit; } /** * Streams a file to the browser and deletes it. * * @param string $file Path. * @param string $name Download name. */ private function send_file( string $file, string $name ): void { nocache_headers(); header( 'Content-Type: application/zip' ); header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $name ) . '"' ); header( 'Content-Length: ' . (string) filesize( $file ) ); readfile( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming a download. wp_delete_file( $file ); exit; } }