'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 '- ' . esc_html( (string) $n ) . ' ' . esc_html( $label ) . '
';
}
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 '';
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'] ];
}
?>
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 '';
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' ) ) );
?>
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 '';
}
$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' ) . '
';
foreach ( $done as $item ) {
echo '- ' . esc_html( self::label( $item['action'] ) ) . ' ' . esc_html( '' !== $item['title'] ? $item['title'] : $item['key'] ) . ' ' . esc_html( self::type_label( (string) $item['kind'], (string) $item['subtype'] ) ) . '
';
}
echo '
';
}
$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 ) . '
';
foreach ( $items as $item ) {
echo '- ' . esc_html( '' !== $item['conflict'] ? ( $pull ? __( 'staging version kept', 'wonderful-sync' ) : __( 'live version kept', 'wonderful-sync' ) ) : self::label( $item['action'] ) ) . ' ' . esc_html( '' !== $item['title'] ? $item['title'] : $item['key'] ) . ( '' !== (string) $item['note'] ? ' – ' . esc_html( $item['note'] ) . '' : '' ) . ' ' . esc_html( self::type_label( (string) $item['kind'], (string) $item['subtype'] ) ) . '
';
}
echo '
';
}
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();
?>
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 ) : ?>
>
()
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;
}
}