array( exists, value ) for each named option. Only Byfaro_Fixes::OPTIONS. */ public static function snapshot( array $names ) { $missing = new stdClass(); $snap = array(); foreach ( $names as $name ) { if ( ! in_array( $name, Byfaro_Fixes::OPTIONS, true ) ) { continue; } $value = get_option( $name, $missing ); $snap[ $name ] = ( $value === $missing ) ? array( 'exists' => false, 'value' => null ) : array( 'exists' => true, 'value' => self::normalise( $value ) ); } return $snap; } private static function restore( array $snap ) { foreach ( $snap as $name => $state ) { if ( ! in_array( $name, Byfaro_Fixes::OPTIONS, true ) || ! is_array( $state ) ) { continue; } if ( ! empty( $state['exists'] ) ) { update_option( $name, $state['value'] ); } else { delete_option( $name ); } } } /** Option names whose current state differs from a snapshot. */ private static function drifted( array $snap ) { $now = self::snapshot( array_keys( $snap ) ); $changed = array(); foreach ( $snap as $name => $state ) { if ( ! isset( $now[ $name ] ) || $now[ $name ] != $state ) { // phpcs:ignore Universal.Operators.StrictComparisons -- normalised values; array key order may differ. $changed[] = $name; } } return $changed; } // ── History storage ───────────────────────────────────────────────────── /** All entries, newest first. */ public static function history() { $list = get_option( self::HISTORY_OPTION, array() ); return is_array( $list ) ? array_values( $list ) : array(); } private static function save_history( array $list ) { update_option( self::HISTORY_OPTION, array_slice( array_values( $list ), 0, self::MAX_ENTRIES ), false ); } public static function find( $change_id ) { foreach ( self::history() as $entry ) { if ( isset( $entry['id'] ) && $entry['id'] === $change_id ) { return $entry; } } return null; } /** The newest applied change of a fix type that hasn't been undone, or null. */ public static function active( $type ) { foreach ( self::history() as $entry ) { if ( isset( $entry['type'], $entry['action'] ) && $type === $entry['type'] && 'apply' === $entry['action'] && empty( $entry['undone_by'] ) ) { return $entry; } } return null; } /** Update one entry in place with the given fields. */ private static function update_entry( $change_id, array $fields ) { $list = self::history(); foreach ( $list as $i => $entry ) { if ( isset( $entry['id'] ) && $entry['id'] === $change_id ) { $list[ $i ] = array_merge( $entry, $fields ); self::save_history( $list ); return $list[ $i ]; } } return null; } /** An entry without its snapshots, for screens and responses. */ public static function public_entry( array $entry ) { unset( $entry['before'], $entry['after'] ); return $entry; } // ── Apply and Undo ────────────────────────────────────────────────────── /** * Apply a fix. Returns the history entry, or WP_Error (nothing written). */ /** * $authorize (optional): called with the fix type once the value is valid * and would really change something, just before anything is written. * Returning a WP_Error stops the fix. The Issues screen uses it to make * sure the site is connected (Byfaro_Admin::authorize_fix). */ public static function apply( $type, $value, $authorize = null ) { $available = Byfaro_Fixes::availability( $type ); if ( is_wp_error( $available ) ) { return $available; } $clean = Byfaro_Fixes::validate( $type, $value ); if ( is_wp_error( $clean ) ) { return $clean; } $site_errors = Byfaro_Fixes::apply_errors( $type, $clean ); if ( is_wp_error( $site_errors ) ) { return $site_errors; } $writes = Byfaro_Fixes::writes( $type, $clean ); foreach ( array_keys( $writes ) as $name ) { if ( ! in_array( $name, Byfaro_Fixes::OPTIONS, true ) ) { return new WP_Error( 'byfaro_unexpected_option', 'A fix tried to write an option outside its list.' ); } } $before = self::snapshot( array_keys( $writes ) ); if ( $authorize ) { $proposed = array(); foreach ( $writes as $name => $new ) { $proposed[ $name ] = array( 'exists' => true, 'value' => self::normalise( $new ) ); } if ( $before == $proposed ) { // phpcs:ignore Universal.Operators.StrictComparisons return new WP_Error( 'byfaro_no_change', 'This fix is already in place.' ); } $allowed = call_user_func( $authorize, $type ); if ( is_wp_error( $allowed ) ) { return $allowed; } } foreach ( $writes as $name => $new ) { update_option( $name, $new ); } Byfaro_Fixes::after_write( array_keys( $writes ) ); $after = self::snapshot( array_keys( $writes ) ); if ( $before == $after ) { // phpcs:ignore Universal.Operators.StrictComparisons return new WP_Error( 'byfaro_no_change', 'This fix is already in place.' ); } $entry = array( 'id' => wp_generate_uuid4(), 'action' => 'apply', 'type' => $type, 'checks' => Byfaro_Fixes::checks_for( $type, $clean ), 'summary' => Byfaro_Fixes::summary( $type, $clean ), 'at' => time(), 'user' => get_current_user_id(), 'before' => $before, 'after' => $after, 'live' => null, 'undoes' => null, 'undone_by' => null, 'mirrored' => false, ); $list = self::history(); array_unshift( $list, $entry ); self::save_history( $list ); return $entry; } /** * Undo one applied change by restoring its exact "before" snapshot. * $force restores even when the options changed since (the fix screen * asks the owner first). Returns the new "undo" history entry or WP_Error. */ public static function undo( $change_id, $force = false ) { $entry = is_string( $change_id ) ? self::find( $change_id ) : null; if ( ! $entry || 'apply' !== $entry['action'] ) { return new WP_Error( 'byfaro_change_not_found', 'That change is no longer in the history.' ); } if ( ! empty( $entry['undone_by'] ) ) { return new WP_Error( 'byfaro_already_undone', 'That change has already been undone.' ); } $before = isset( $entry['before'] ) && is_array( $entry['before'] ) ? $entry['before'] : array(); $after = isset( $entry['after'] ) && is_array( $entry['after'] ) ? $entry['after'] : array(); // The snapshot must cover exactly the options this fix type writes. $expected = Byfaro_Fixes::option_names( $entry['type'] ); if ( ! $expected || array_diff( $expected, array_keys( $before ) ) || array_diff( array_keys( $before ), $expected ) ) { return new WP_Error( 'byfaro_bad_snapshot', 'The saved snapshot for that change is incomplete, so it cannot be undone safely.' ); } $changed = self::drifted( $after ); if ( $changed && ! $force ) { return new WP_Error( 'byfaro_changed_since', 'These settings have changed since that fix was applied.', array( 'options' => $changed ) ); } $current = self::snapshot( array_keys( $before ) ); self::restore( $before ); Byfaro_Fixes::after_write( array_keys( $before ) ); $restored = self::snapshot( array_keys( $before ) ); $undo = array( 'id' => wp_generate_uuid4(), 'action' => 'undo', 'type' => $entry['type'], 'checks' => $entry['checks'], /* translators: %s: the summary of the change being undone */ 'summary' => sprintf( __( 'Undid: %s', 'byfaro' ), $entry['summary'] ), 'at' => time(), 'user' => get_current_user_id(), 'before' => $current, 'after' => $restored, 'live' => null, 'undoes' => $entry['id'], 'undone_by' => null, 'mirrored' => false, ); $list = self::history(); foreach ( $list as $i => $item ) { if ( $item['id'] === $entry['id'] ) { $list[ $i ]['undone_by'] = $undo['id']; } } array_unshift( $list, $undo ); self::save_history( $list ); return $undo; } /** * Record what the live-site recheck found for an applied fix: true when * every check passed, false when any failed, null when byfaro couldn't tell. */ public static function set_live( $change_id, $live, $why = null ) { // Why it isn't live (Byfaro_Admin::live_reason), kept so Change history // still says so after a reload, not only right after Check again (Jack, 6 Oct). return self::update_entry( $change_id, array( 'live' => $live, 'live_why' => $why, 'live_at' => time(), 'mirrored' => false ) ); } // ── Mirror to the byfaro account ────────────────────────────────────────── /** Send unmirrored entries (metadata only) to byfaro.ai. Safe to call any time. */ public static function mirror_pending() { if ( ! Byfaro_Api::is_connected() || get_option( 'byfaro_key_invalid' ) ) { return; } $pending = array_filter( self::history(), function ( $e ) { return isset( $e['mirrored'] ) && false === $e['mirrored']; } ); if ( ! $pending ) { return; } $batch = array_slice( array_reverse( $pending ), 0, self::MIRROR_BATCH ); // oldest first $payload = array(); $sent = array(); foreach ( $batch as $e ) { $payload[] = array( 'id' => $e['id'], 'type' => $e['type'], 'action' => $e['action'], 'checks' => $e['checks'], 'summary' => $e['summary'], 'live' => $e['live'], 'undoes' => $e['undoes'], 'at' => (int) $e['at'], ); // The exact state sent, so an entry updated mid-request is sent again. $sent[ $e['id'] ] = $e['live']; } list( $code ) = Byfaro_Api::request( 'POST', '/api/plugin/changes', array( 'changes' => $payload ), 15 ); if ( 200 !== $code && 400 !== $code ) { return; // Try again at the next change or the daily maintenance run. } // 400: byfaro refused the batch as malformed. Retrying the same data can't // succeed, so mark it rather than resending it forever. $list = self::history(); foreach ( $list as $i => $e ) { // array_key_exists, not isset: a change not checked yet was sent with live = null. if ( array_key_exists( $e['id'], $sent ) && $sent[ $e['id'] ] === $e['live'] ) { $list[ $i ]['mirrored'] = ( 200 === $code ) ? true : 'rejected'; } } self::save_history( $list ); } }