Objects already captured in this operation. */ private static $seen = array(); /** Changes kept in the undo history, per user. */ const KEEP = 20; /** Changes younger than this (seconds) are never pruned. */ const KEEP_RECENT = 600; /** Kinds of change the free plugin records and can undo. */ const KINDS = array( 'post', 'created_post', 'term', 'created_term' ); /** * Start an operation. * * @param string $tool Tool name. */ public static function begin( string $tool ): string { self::$op = 'op_' . gmdate( 'ymdHis' ) . '_' . substr( bin2hex( random_bytes( 4 ) ), 0, 6 ); self::$tool = $tool; self::$seen = array(); self::watch( true ); return self::$op; } /** @var bool */ private static $watching = false; /** * Run a change that keeps its own backup (for example a replaced media file), * so a normal undo cannot restore its data without its files. * * @param callable $fn Change. * @return mixed */ public static function without( callable $fn ) { $was = self::$watching; self::watch( false ); try { return $fn(); } finally { self::watch( $was ); } } /** * While a write tool runs, capture every post, post meta and term before * it changes, whichever API (WordPress, Elementor, WooCommerce, form * plugins...) makes the change. * * @param bool $on On/off. */ private static function watch( bool $on ): void { if ( $on === self::$watching ) { return; } self::$watching = $on; $fn = $on ? 'add_filter' : 'remove_filter'; $fn( 'wp_insert_post_data', array( __CLASS__, 'on_post_data' ), 1, 2 ); $fn( 'wp_insert_post', array( __CLASS__, 'on_insert_post' ), 1, 3 ); $fn( 'add_attachment', array( __CLASS__, 'on_add_attachment' ), 1, 1 ); $fn( 'pre_trash_post', array( __CLASS__, 'on_pre_post' ), 1, 2 ); $fn( 'pre_delete_post', array( __CLASS__, 'on_pre_post' ), 1, 2 ); foreach ( array( 'update_post_metadata', 'add_post_metadata', 'delete_post_metadata' ) as $hook ) { $fn( $hook, array( __CLASS__, 'on_meta' ), 1, 2 ); } $fn( 'edit_terms', array( __CLASS__, 'on_term' ), 1, 1 ); $fn( 'created_term', array( __CLASS__, 'on_created_term' ), 10, 3 ); } /** * Before a post row update. * * @param array $data Data. * @param array $postarr Raw. * @return array */ public static function on_post_data( $data, $postarr ) { if ( ! empty( $postarr['ID'] ) && ! in_array( $data['post_type'] ?? '', array( 'revision', 'nav_menu_item', 'customize_changeset' ), true ) ) { self::post( (int) $postarr['ID'] ); } return $data; } /** * After insert: record created posts. * * @param int $id Post id. * @param \WP_Post $post Post. * @param bool $update Update? */ public static function on_insert_post( $id, $post, $update ) { if ( ! $update && $post && ! in_array( $post->post_type, array( 'revision', 'customize_changeset' ), true ) && 'auto-draft' !== $post->post_status ) { self::created_post( (int) $id ); self::$seen[ 'post:' . $id ] = true; // Its later meta writes belong to the creation. } } /** * New media: WordPress does not fire wp_insert_post for attachments, so record * the upload here; undoing it deletes the file again. * * @param int $id Attachment id. */ public static function on_add_attachment( $id ) { self::created_post( (int) $id ); self::$seen[ 'post:' . (int) $id ] = true; } /** * Before trash/delete. * * @param mixed $check Short-circuit value. * @param \WP_Post $post Post. * @return mixed */ public static function on_pre_post( $check, $post ) { if ( $post instanceof \WP_Post ) { self::post( $post->ID ); } return $check; } /** * Before post meta changes. * * @param mixed $check Short-circuit. * @param int $id Object id. * @return mixed */ public static function on_meta( $check, $id ) { $type = get_post_type( (int) $id ); if ( $type && 'revision' !== $type ) { self::post( (int) $id ); } return $check; } /** * Before term update. * * @param int $id Term id. */ public static function on_term( $id ) { self::term( (int) $id ); } /** * A category, tag or term was created: undoing deletes it again. * * @param int $id Term id. * @param int $tt_id Term taxonomy id. * @param string $taxonomy Taxonomy. */ public static function on_created_term( $id, $tt_id = 0, $taxonomy = '' ) { $t = get_term( (int) $id, (string) $taxonomy ); $n = $t instanceof \WP_Term ? $t->name : '#' . (int) $id; self::store( 'created_term', (string) (int) $id, 'New term "' . $n . '"', array( 'id' => (int) $id, 'taxonomy' => (string) $taxonomy ) ); } /** * End the operation. */ public static function resume( string $op, string $tool ): void { self::$op = $op; self::$tool = $tool; self::$seen = array(); } /** * End the current operation. */ public static function end(): void { $had = '' !== self::$op; self::watch( false ); if ( $had ) { self::record_after(); } self::$op = ''; self::$seen = array(); if ( $had ) { self::prune(); } } /** * After the operation: note the state each post was left in, so a later * undo can tell whether someone changed it since. */ private static function record_after(): void { $ids = array(); foreach ( array_keys( self::$seen ) as $key ) { if ( 0 === strpos( $key, 'post:' ) || 0 === strpos( $key, 'created_post:' ) ) { $ids[ (int) substr( $key, strpos( $key, ':' ) + 1 ) ] = true; } } if ( ! $ids ) { return; } global $wpdb; $t = Installer::table( 'history' ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, object_ref, data FROM %i WHERE op_id = %s AND kind IN ('post','created_post')", $t, self::$op ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, rows of this request. foreach ( (array) $rows as $row ) { if ( ! isset( $ids[ (int) $row->object_ref ] ) ) { continue; } $data = self::decode( (string) $row->data ); if ( ! $data ) { continue; } $data['_after'] = self::fingerprint( (int) $row->object_ref ); $wpdb->update( $t, array( 'data' => self::encode( $data ) ), array( 'id' => (int) $row->id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table write. } } /** * Fingerprint of a post's saved state (fields, meta, terms); '' when it is gone. * The modified date is left out on purpose: undo itself sets it, and undoing * several changes in a row must still match. * * @param int $id Post id. */ private static function fingerprint( int $id ): string { clean_post_cache( $id ); $p = $id > 0 ? get_post( $id ) : null; if ( ! $p ) { return ''; } $fields = array(); foreach ( array( 'post_title', 'post_content', 'post_excerpt', 'post_status', 'post_name', 'post_parent', 'menu_order', 'post_password', 'post_author', 'post_date_gmt' ) as $f ) { $fields[ $f ] = (string) $p->$f; } $skip = array( '_edit_lock', '_edit_last', '_elementor_css', '_elementor_element_cache', '_elementor_page_assets', '_elementor_controls_usage', '_elementor_version', '_elementor_pro_version', '_elementor_screenshot', '_elementor_screenshot_failed', '_wp_old_slug', '_wp_old_date', '_encloseme', '_pingme' ); $meta = array(); foreach ( get_post_meta( $id ) as $k => $vals ) { if ( ! in_array( $k, $skip, true ) ) { $vals = array_map( 'strval', (array) $vals ); if ( '_elementor_data' === $k ) { // Same layout saved again can differ only in JSON escaping. $vals = array_map( static function ( $v ) { $d = json_decode( $v, true ); return is_array( $d ) ? (string) wp_json_encode( $d ) : $v; }, $vals ); } sort( $vals ); $meta[ $k ] = $vals; } } ksort( $meta ); $terms = array(); foreach ( get_object_taxonomies( $p->post_type ) as $tax ) { $tids = wp_get_object_terms( $id, $tax, array( 'fields' => 'ids' ) ); if ( ! is_wp_error( $tids ) && $tids ) { $tids = array_map( 'intval', $tids ); sort( $tids ); $terms[ $tax ] = $tids; } } ksort( $terms ); return md5( (string) wp_json_encode( array( $fields, $meta, $terms ) ) ); } /** * Keep the newest KEEP changes of each user. Changes from the last * KEEP_RECENT seconds are always kept, so one busy client cannot push out * a change that is still being reviewed. */ public static function prune(): void { global $wpdb; $t = Installer::table( 'history' ); $user = get_current_user_id(); $cut = $wpdb->get_var( $wpdb->prepare( 'SELECT MIN(id) AS first_id FROM %i WHERE user_id = %d GROUP BY op_id ORDER BY first_id DESC LIMIT 1 OFFSET %d', $t, $user, self::KEEP - 1 ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table pruning. if ( $cut ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE user_id = %d AND id < %d AND created_at < %s', $t, $user, (int) $cut, gmdate( 'Y-m-d H:i:s', time() - self::KEEP_RECENT ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table pruning. } } /** * Current operation id. */ public static function op(): string { return self::$op; } /** * Did this operation record anything? */ public static function has_entries(): bool { if ( self::$seen ) { return true; } if ( '' === self::$op ) { return false; } global $wpdb; return (bool) $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i WHERE op_id = %s LIMIT 1', Installer::table( 'history' ), self::$op ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must reflect this request. } /** * Store a snapshot once per object per operation. * * @param string $kind Kind. * @param string $ref Object reference. * @param string $label Human label. * @param array $data State. */ private static function store( string $kind, string $ref, string $label, array $data ): void { if ( '' === self::$op || isset( self::$seen[ $kind . ':' . $ref ] ) ) { return; } self::$seen[ $kind . ':' . $ref ] = true; global $wpdb; $blob = self::encode( $data ); $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery Installer::table( 'history' ), array( 'op_id' => self::$op, 'created_at' => gmdate( 'Y-m-d H:i:s' ), 'user_id' => get_current_user_id(), 'tool' => mb_substr( self::$tool, 0, 120 ), 'task' => self::$task, 'kind' => $kind, 'object_ref' => mb_substr( $ref, 0, 190 ), 'label' => mb_substr( $label, 0, 190 ), 'data' => $blob, ) ); } /** * Encode a snapshot for storage. * * @param array $data State. */ private static function encode( array $data ): string { $blob = (string) wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR ); if ( function_exists( 'gzcompress' ) ) { $blob = 'gz:' . base64_encode( gzcompress( $blob, 6 ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode } // Snapshots can hold settings, API keys or code: they are encrypted at rest. return 'x1:' . \HandyMCP\Support\Crypto::encrypt( $blob ); } /** * Decode a stored blob. * * @param string $blob Blob. * @return array */ private static function decode( string $blob ): array { if ( 0 === strpos( $blob, 'x1:' ) ) { $blob = (string) \HandyMCP\Support\Crypto::decrypt( substr( $blob, 3 ) ); if ( '' === $blob ) { return array(); } } if ( 0 === strpos( $blob, 'gz:' ) ) { $blob = (string) gzuncompress( base64_decode( substr( $blob, 3 ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode } $data = json_decode( $blob, true ); return is_array( $data ) ? $data : array(); } /* ------------------------------------------------------------------ capture */ /** * Post, page, product, template... with all meta and terms. A post that * does not exist yet is recorded as "created" so revert deletes it. * * @param int $id Post id (0 = about to be created, call created_post() after). */ public static function post( int $id ): void { // get_post( 0 ) returns the global post, e.g. for delete_post_meta_by_key() (object id 0). $p = $id > 0 ? get_post( $id ) : null; if ( ! $p ) { return; } $meta = array(); foreach ( get_post_meta( $id ) as $k => $vals ) { if ( in_array( $k, array( '_edit_lock', '_edit_last', '_elementor_css', '_elementor_element_cache' ), true ) ) { continue; } // Kept exactly as stored (serialized), so objects such as Beaver // Builder nodes come back as objects, not arrays. $meta[ $k ] = $vals; } $terms = array(); foreach ( get_object_taxonomies( $p->post_type ) as $tax ) { $ids = wp_get_object_terms( $id, $tax, array( 'fields' => 'ids' ) ); if ( ! is_wp_error( $ids ) ) { $terms[ $tax ] = $ids; } } self::store( 'post', (string) $id, self::type_label( $p->post_type ) . ' "' . $p->post_title . '"', array( 'post' => $p->to_array(), 'meta_raw' => $meta, 'terms' => $terms ) ); } /** * Record a newly created post (revert moves it to the trash). * * @param int $id Post id. */ public static function created_post( int $id ): void { self::store( 'created_post', (string) $id, 'New ' . strtolower( self::type_label( (string) get_post_type( $id ) ) ) . ' "' . get_the_title( $id ) . '"', array( 'id' => $id ) ); } /** * Term with meta. * * @param int $id Term id. */ public static function term( int $id ): void { $t = get_term( $id ); if ( ! $t || is_wp_error( $t ) ) { return; } $objects = get_objects_in_term( $id, $t->taxonomy ); self::store( 'term', (string) $id, $t->taxonomy . ' "' . $t->name . '"', array( 'term' => (array) $t, 'meta' => get_term_meta( $id ), 'objects' => is_array( $objects ) ? array_map( 'intval', $objects ) : array() ) ); } /** * Notes for the person, collected while reverting. * * @var string[] */ private static $notes = array(); /** * Does a WPCode snippet with these type terms run PHP? * * @param array $term_ids wpcode_type term ids. */ private static function wpcode_runs_php( array $term_ids ): bool { foreach ( $term_ids as $tid ) { $t = get_term( (int) $tid, 'wpcode_type' ); if ( $t && ! is_wp_error( $t ) && ! in_array( $t->slug, array( 'php', 'universal' ), true ) ) { return false; } } return true; // Unknown type: treat it like PHP. } /** * WPCode keeps its own list of snippets to run: rebuild it after undo. * * @param string $type Post type. */ private static function after_snippet_post( string $type ): void { if ( 'wpcode' === $type && function_exists( 'wpcode' ) && isset( wpcode()->cache ) && method_exists( wpcode()->cache, 'cache_all_loaded_snippets' ) ) { wpcode()->cache->cache_all_loaded_snippets(); } } /* ------------------------------------------------------------------ read / revert */ /** * Operations, newest first. * * @param int $limit Limit. * @param int $offset Offset. * @param int $object Only operations touching this post id. * @return array> */ /** * One operation for the change view: tool, time, user, labels and undo state. * * @param string $op Operation id. * @return array */ public static function op_summary( string $op ): array { global $wpdb; $row = \HandyMCP\Support\Env::is_sqlite() ? $wpdb->get_row( $wpdb->prepare( "SELECT op_id, MIN(created_at) AS created_at, MIN(user_id) AS user_id, MIN(tool) AS tool, MAX(reverted_at) AS reverted_at, GROUP_CONCAT(label, ' | ') AS labels FROM %i WHERE op_id = %s GROUP BY op_id", Installer::table( 'history' ), $op ), ARRAY_A ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current. : $wpdb->get_row( $wpdb->prepare( "SELECT op_id, MIN(created_at) AS created_at, MIN(user_id) AS user_id, MIN(tool) AS tool, MAX(reverted_at) AS reverted_at, GROUP_CONCAT(label SEPARATOR ' | ') AS labels FROM %i WHERE op_id = %s GROUP BY op_id", Installer::table( 'history' ), $op ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current. return is_array( $row ) ? $row : array(); } public static function operations( int $limit = 30, int $offset = 0, int $object = 0 ): array { global $wpdb; $t = Installer::table( 'history' ); if ( $object ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, history list must be current. $rows = \HandyMCP\Support\Env::is_sqlite() ? $wpdb->get_results( $wpdb->prepare( "SELECT op_id, MIN(created_at) AS created_at, MIN(user_id) AS user_id, MIN(tool) AS tool, MIN(task) AS task, GROUP_CONCAT(label, ' | ') AS labels, MAX(reverted_at) AS reverted_at, COUNT(*) AS items FROM %i WHERE object_ref = %s AND kind IN ('post','created_post') GROUP BY op_id ORDER BY MIN(id) DESC LIMIT %d OFFSET %d", $t, (string) $object, $limit, $offset ), ARRAY_A ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current. : $wpdb->get_results( $wpdb->prepare( "SELECT op_id, MIN(created_at) AS created_at, MIN(user_id) AS user_id, MIN(tool) AS tool, MIN(task) AS task, GROUP_CONCAT(label SEPARATOR ' | ') AS labels, MAX(reverted_at) AS reverted_at, COUNT(*) AS items FROM %i WHERE object_ref = %s AND kind IN ('post','created_post') GROUP BY op_id ORDER BY MIN(id) DESC LIMIT %d OFFSET %d", $t, (string) $object, $limit, $offset ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current. } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, history list must be current. $rows = \HandyMCP\Support\Env::is_sqlite() ? $wpdb->get_results( $wpdb->prepare( "SELECT op_id, MIN(created_at) AS created_at, MIN(user_id) AS user_id, MIN(tool) AS tool, MIN(task) AS task, GROUP_CONCAT(label, ' | ') AS labels, MAX(reverted_at) AS reverted_at, COUNT(*) AS items FROM %i GROUP BY op_id ORDER BY MIN(id) DESC LIMIT %d OFFSET %d", $t, $limit, $offset ), ARRAY_A ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current. : $wpdb->get_results( $wpdb->prepare( "SELECT op_id, MIN(created_at) AS created_at, MIN(user_id) AS user_id, MIN(tool) AS tool, MIN(task) AS task, GROUP_CONCAT(label SEPARATOR ' | ') AS labels, MAX(reverted_at) AS reverted_at, COUNT(*) AS items FROM %i GROUP BY op_id ORDER BY MIN(id) DESC LIMIT %d OFFSET %d", $t, $limit, $offset ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current. } return is_array( $rows ) ? $rows : array(); } /** * Fingerprint of a post's current state ('' when it is gone). * * @param int $id Post id. */ public static function state( int $id ): string { return self::fingerprint( $id ); } /** * Who made an operation (0 when it is not in the history). * * @param string $op Operation id. */ public static function op_user( string $op ): int { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT user_id FROM %i WHERE op_id = %s ORDER BY id ASC LIMIT 1', Installer::table( 'history' ), $op ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current. } /** * Rows of one operation, decoded. * * @param string $op Operation id. * @return array}> */ public static function op_rows( string $op ): array { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, kind, object_ref, label, data FROM %i WHERE op_id = %s ORDER BY id ASC', Installer::table( 'history' ), $op ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, rows of this request. $out = array(); foreach ( (array) $rows as $row ) { $out[] = array( 'id' => (int) $row->id, 'kind' => (string) $row->kind, 'ref' => (string) $row->object_ref, 'label' => (string) $row->label, 'data' => self::decode( (string) $row->data ) ); } return $out; } /** * Readable name of a post type. * * @param string $type Post type. */ private static function type_label( string $type ): string { $o = get_post_type_object( $type ); return $o && ! empty( $o->labels->singular_name ) ? (string) $o->labels->singular_name : ucfirst( $type ); } /** * Revert several operations, newest first (a whole task, or every change to one page). * * @param string[] $op_ids Operation ids. * @param bool $force Undo even where a post was changed again since. * @return array */ public static function revert_many( array $op_ids, bool $force = false ): array { global $wpdb; $t = Installer::table( 'history' ); if ( ! $op_ids ) { return array( 'reverted' => array(), 'failed' => array(), 'message' => 'Nothing to undo.' ); } $in = implode( ',', array_fill( 0, count( $op_ids ), '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- custom table; $in is placeholders built from count($op_ids). $order = $wpdb->get_col( $wpdb->prepare( "SELECT op_id FROM %i WHERE op_id IN ($in) AND reverted_at IS NULL GROUP BY op_id ORDER BY MIN(id) DESC", array_merge( array( $t ), $op_ids ) ) ); $own = '' === self::$op; if ( $own ) { self::begin( 'undo:' . implode( ',', array_slice( $order, 0, 3 ) ) . ( count( $order ) > 3 ? ',…' : '' ) ); } $done = array(); $failed = array(); $notes = array(); $ok = 0; $chain = $force; foreach ( $order as $op ) { // Newest first: once the newest change is undone, the older ones in this // batch are part of the same chain and no longer conflict with each other. $r = self::revert( (string) $op, $chain ); if ( is_wp_error( $r ) ) { $failed[] = $op . ': ' . $r->get_error_message(); if ( ! $chain ) { break; } continue; } ++$ok; $chain = true; $done = array_merge( $done, $r['reverted'] ); $failed = array_merge( $failed, $r['failed'] ); $notes = array_merge( $notes, (array) ( $r['warnings'] ?? array() ) ); } self::$tool = 'undo:' . implode( ',', array_slice( $order, 0, 3 ) ) . ( count( $order ) > 3 ? ',…' : '' ); $op_now = self::$op; if ( $own ) { self::end(); } $out = array( 'undone_changes' => $ok, 'reverted' => array_values( array_unique( $done ) ), 'failed' => $failed, 'revert_operation' => $op_now, 'message' => $failed ? 'Partly undone.' : 'Undone.' ); if ( $notes ) { $out['warnings'] = array_values( array_unique( $notes ) ); } return $out; } /** * Operation ids of one task (conversation), or of every change to one post. * * @param string $task Task id. * @param int $post_id Post id. * @param string $since Only changes after this GMT time. * @return string[] */ public static function ops_for( string $task = '', int $post_id = 0, string $since = '' ): array { global $wpdb; $t = Installer::table( 'history' ); $where = array( 'reverted_at IS NULL' ); if ( ! $post_id ) { $where[] = "tool NOT LIKE 'undo:%%'"; } // For one page, open undo/redo operations are included: walking every open // change newest first is what brings the page back to its starting state. $args = array( $t ); if ( $task ) { $where[] = 'task = %s'; $args[] = $task; } if ( $post_id ) { $where[] = "object_ref = %s AND kind IN ('post','created_post')"; $args[] = (string) $post_id; } if ( $since ) { $where[] = 'created_at >= %s'; $args[] = $since; } if ( ! $task && ! $post_id ) { return array(); } $cond = implode( ' AND ', $where ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- custom table; $cond only joins fixed condition strings, values go through placeholders. $ops = array_values( array_unique( (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT op_id FROM %i WHERE {$cond}", $args ) ) ) ); if ( ! $post_id || ! $ops ) { return $ops; } // An undo whose change is already undone cancels out with it: reverting it again would bring that change back. $keep = array(); foreach ( $ops as $op ) { $tool = (string) $wpdb->get_var( $wpdb->prepare( 'SELECT tool FROM %i WHERE op_id = %s LIMIT 1', $t, $op ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current. if ( 0 === strpos( $tool, 'undo:' ) ) { $target = substr( $tool, 5 ); $closed = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE op_id = %s AND reverted_at IS NOT NULL', $t, $target ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, must be current. if ( (int) $closed > 0 ) { continue; } } $keep[] = $op; } return $keep; } /** * Revert one operation. * * A post changed again after this operation (by a person in the editor or * by a later change) is not overwritten: the revert stops with a "conflict" * error, unless $force is true. * * @param string $op_id Operation id. * @param bool $force Undo even where a post was changed again since. * @return array|\WP_Error */ public static function revert( string $op_id, bool $force = false ) { global $wpdb; $t = Installer::table( 'history' ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE op_id = %s ORDER BY id DESC', $t, $op_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, revert needs current rows. if ( ! $rows ) { return new \WP_Error( 'not_found', 'No history entry with that id.' ); } if ( $rows[0]->reverted_at ) { return new \WP_Error( 'already_reverted', 'This change was already reverted. Revert the revert operation to redo it.' ); } $snaps = array(); $conflicts = array(); foreach ( $rows as $row ) { if ( ! in_array( (string) $row->kind, self::KINDS, true ) ) { return new \WP_Error( 'not_supported', 'This change was made with HandyMCP Pro (settings, files, plugins or users) and can only be undone there. Nothing was undone.' ); } $snaps[ (int) $row->id ] = self::decode( (string) $row->data ); $snap = $snaps[ (int) $row->id ]; if ( ! $force && isset( $snap['_after'] ) && in_array( (string) $row->kind, array( 'post', 'created_post' ), true ) && self::fingerprint( (int) $row->object_ref ) !== (string) $snap['_after'] ) { $conflicts[ (string) $row->object_ref ] = $row->label; } } if ( $conflicts ) { return new \WP_Error( 'conflict', 'Nothing was undone: ' . implode( ', ', array_values( $conflicts ) ) . ' changed again after this change (edited by someone or by a later change). Undoing now would overwrite those newer edits. Undo the newer changes first, or edit the content by hand.', array( 'objects' => array_keys( $conflicts ) ) ); } // Claim the operation, so two undo requests at the same moment cannot both restore it. $claimed = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET reverted_at = %s WHERE op_id = %s AND reverted_at IS NULL', $t, gmdate( 'Y-m-d H:i:s' ), $op_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table write. if ( ! $claimed ) { return new \WP_Error( 'already_reverted', 'This change was already reverted. Revert the revert operation to redo it.' ); } $own = '' === self::$op; if ( $own ) { self::begin( 'undo:' . $op_id ); } else { self::$tool = 'undo:' . $op_id; } $done = array(); $failed = array(); $created = array(); self::$notes = array(); foreach ( $rows as $row ) { if ( 'created_post' === $row->kind ) { $created[ $row->object_ref ] = true; } } // The snapshots are this plugin's own copies of what was saved before, // so they go back exactly, without the HTML filter of the current user // (MCP requests never have unfiltered_html). $kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ); if ( $kses ) { kses_remove_filters(); } try { foreach ( $rows as $row ) { if ( 'post' === $row->kind && isset( $created[ $row->object_ref ] ) ) { continue; // The post did not exist before this change. } $snap = $snaps[ (int) $row->id ]; if ( ! $snap && 'created_post' !== $row->kind ) { $failed[] = $row->label . ': the saved copy could not be read (damaged or from another site key).'; continue; } $res = self::restore( (string) $row->kind, (string) $row->object_ref, $snap ); if ( is_wp_error( $res ) ) { $failed[] = $row->label . ': ' . $res->get_error_message(); } else { $done[] = $row->label; } } } finally { if ( $kses ) { kses_init_filters(); } } $revert_op = self::$op; if ( $own ) { self::end(); } if ( class_exists( '\Elementor\Plugin' ) ) { \Elementor\Plugin::$instance->files_manager->clear_cache(); } $out = array( 'reverted' => $done, 'failed' => $failed, 'revert_operation' => $revert_op, 'message' => $failed ? 'Partly reverted.' : 'Reverted.' ); if ( self::$notes ) { $out['warnings'] = array_values( array_unique( self::$notes ) ); } return $out; } /** * Restore one snapshot (snapshotting the current state first). * * @param string $kind Kind. * @param string $ref Reference. * @param array $d Data. * @return true|\WP_Error */ private static function restore( string $kind, string $ref, array $d ) { switch ( $kind ) { case 'post': if ( ! isset( $d['post'] ) || ! is_array( $d['post'] ) || ! isset( $d['post']['ID'], $d['post']['post_type'], $d['post']['post_title'] ) ) { return new \WP_Error( 'damaged', 'The saved copy is incomplete.' ); } $post = $d['post']; $id = (int) $post['ID']; if ( get_post( $id ) ) { if ( ! current_user_can( 'edit_post', $id ) ) { return new \WP_Error( 'forbidden', 'Your account cannot edit item #' . $id . '.' ); } self::post( $id ); } else { $type_obj = get_post_type_object( (string) $post['post_type'] ); if ( ! $type_obj || ! current_user_can( $type_obj->cap->create_posts ) ) { return new \WP_Error( 'forbidden', 'Your account cannot create this kind of item again.' ); } } $fields = array_intersect_key( $post, array_flip( array( 'ID', 'post_title', 'post_content', 'post_excerpt', 'post_status', 'post_name', 'post_parent', 'menu_order', 'post_date', 'post_date_gmt', 'post_password', 'comment_status', 'post_author', 'post_mime_type' ) ) ); if ( 'wpcode' === $post['post_type'] && 'publish' === ( $fields['post_status'] ?? '' ) && 'publish' !== get_post_status( $id ) && self::wpcode_runs_php( (array) ( $d['terms']['wpcode_type'] ?? array() ) ) ) { // A PHP snippet is never switched back on by undo: a person does that. $fields['post_status'] = 'draft'; self::$notes[] = 'Snippet "' . $post['post_title'] . '" was put back switched off. PHP snippets are only switched on by a person, in WPCode > Code Snippets.'; } if ( ! get_post( $id ) ) { $fields['import_id'] = $id; unset( $fields['ID'] ); $fields['post_type'] = $post['post_type']; $new = wp_insert_post( wp_slash( $fields ), true ); if ( is_wp_error( $new ) ) { return $new; } $id = (int) $new; } else { $res = wp_update_post( wp_slash( $fields ), true ); if ( is_wp_error( $res ) ) { return $res; } } $raw = isset( $d['meta_raw'] ); $saved = (array) ( $raw ? $d['meta_raw'] : ( $d['meta'] ?? array() ) ); $current = array_keys( get_post_meta( $id ) ); // Undo never changes media files, so the data that describes them stays as it is now. $files = 'attachment' === $post['post_type'] ? array( '_wp_attachment_metadata', '_wp_attached_file', '_wp_attachment_backup_sizes', '_handymcp_replace_backup' ) : array(); foreach ( $files as $k ) { unset( $saved[ $k ] ); } foreach ( $current as $k ) { if ( ! isset( $saved[ $k ] ) && ! in_array( $k, array_merge( array( '_edit_lock', '_edit_last' ), $files ), true ) ) { delete_post_meta( $id, $k ); } } foreach ( $saved as $k => $vals ) { delete_post_meta( $id, $k ); foreach ( (array) $vals as $v ) { $v = $raw ? maybe_unserialize( $v ) : $v; // add_post_meta unslashes, so slash every value (arrays and objects too). add_post_meta( $id, $k, wp_slash( $v ) ); } } foreach ( (array) ( $d['terms'] ?? array() ) as $tax => $ids ) { wp_set_object_terms( $id, array_map( 'intval', (array) $ids ), $tax ); } delete_post_meta( $id, '_elementor_css' ); delete_post_meta( $id, '_elementor_element_cache' ); clean_post_cache( $id ); if ( function_exists( 'wc_delete_product_transients' ) && 'product' === $post['post_type'] ) { wc_delete_product_transients( $id ); } Util::purge_post( $id ); self::after_snippet_post( (string) $post['post_type'] ); return true; case 'created_post': $id = (int) ( $d['id'] ?? $ref ); if ( get_post( $id ) ) { if ( ! current_user_can( 'delete_post', $id ) ) { return new \WP_Error( 'forbidden', 'Your account cannot delete item #' . $id . '.' ); } self::post( $id ); $type = (string) get_post_type( $id ); if ( 'attachment' === $type ) { // Media has no trash unless MEDIA_TRASH is on: delete the upload and its files. if ( ! wp_delete_attachment( $id, true ) ) { return new \WP_Error( 'delete_failed', 'The added media item #' . $id . ' could not be deleted.' ); } } else { wp_trash_post( $id ); } self::after_snippet_post( $type ); } return true; case 'created_term': $tid = (int) ( $d['id'] ?? $ref ); $tax = (string) ( $d['taxonomy'] ?? '' ); if ( $tid && '' !== $tax && get_term( $tid, $tax ) instanceof \WP_Term ) { if ( ! current_user_can( 'delete_term', $tid ) ) { return new \WP_Error( 'forbidden', 'Your account cannot delete this term.' ); } $del = wp_delete_term( $tid, $tax ); if ( is_wp_error( $del ) ) { return $del; } } return true; case 'term': if ( ! isset( $d['term'] ) || ! is_array( $d['term'] ) || ! isset( $d['term']['taxonomy'], $d['term']['name'], $d['term']['slug'], $d['term']['description'], $d['term']['parent'] ) ) { return new \WP_Error( 'damaged', 'The saved copy is incomplete.' ); } $t = $d['term']; if ( get_term( (int) $ref, $t['taxonomy'] ) instanceof \WP_Term ) { if ( ! current_user_can( 'edit_term', (int) $ref ) ) { return new \WP_Error( 'forbidden', 'Your account cannot edit this term.' ); } self::term( (int) $ref ); wp_update_term( (int) $ref, $t['taxonomy'], array( 'name' => $t['name'], 'slug' => $t['slug'], 'description' => $t['description'], 'parent' => $t['parent'] ) ); return true; } // The term was deleted: create it again and put it back on its posts. $tax_obj = get_taxonomy( (string) $t['taxonomy'] ); if ( ! $tax_obj || ! current_user_can( $tax_obj->cap->manage_terms ) ) { return new \WP_Error( 'forbidden', 'Your account cannot create terms in this taxonomy.' ); } $made = wp_insert_term( $t['name'], $t['taxonomy'], array( 'slug' => $t['slug'], 'description' => $t['description'], 'parent' => (int) $t['parent'] ) ); if ( is_wp_error( $made ) ) { return $made; } foreach ( (array) ( $d['meta'] ?? array() ) as $key => $values ) { foreach ( (array) $values as $value ) { add_term_meta( (int) $made['term_id'], $key, maybe_unserialize( $value ) ); } } foreach ( (array) ( $d['objects'] ?? array() ) as $object ) { wp_add_object_terms( (int) $object, (int) $made['term_id'], $t['taxonomy'] ); } return true; } return new \WP_Error( 'unknown_kind', 'This change was made with HandyMCP Pro and can only be undone there.' ); } }