log = $log ?? new ChangeLog(); $this->rewriter = $rewriter ?? new SourceRewriter( $this->log ); } /** * Register the job hook. */ public function register(): void { add_action( self::HOOK, array( $this, 'handle_job' ) ); } /** * Dry run: describe exactly what an action would change. Writes nothing (except a preview token). * * @param string $action Action. * @param int[] $link_ids Link IDs. * @param string $new_url New URL for edit/redirect. * @return array|\WP_Error */ public function preview( string $action, array $link_ids, string $new_url = '' ) { $valid = $this->validate( $action, $link_ids, $new_url ); if ( is_wp_error( $valid ) ) { return $valid; } list( $link_ids, $new_url ) = $valid; $links = $this->load_links( $link_ids ); $changes = array(); $skipped = 0; $total = 0; $rows = array(); foreach ( $links as $link ) { $target = $this->target_for( $action, $link, $new_url ); $rows[] = array( 'id' => (int) $link->id, 'url' => (string) $link->url, 'status' => (string) $link->status, 'new_url' => $target, ); } $units = array(); if ( in_array( $action, self::CONTENT_ACTIONS, true ) ) { $plan = $this->plan_sources( $links, $action, $new_url ); if ( count( $plan ) > self::MAX_SOURCES ) { return new \WP_Error( 'unbreak_too_many_places', /* translators: 1: number of places, 2: maximum. */ sprintf( __( 'These links appear in %1$d places. To keep every change undoable, one action can change at most %2$d places. Select fewer links and repeat.', 'unbreak' ), count( $plan ), self::MAX_SOURCES ), array( 'status' => 400 ) ); } foreach ( $plan as $source ) { $lock = self::editor_lock_note( $source['type'], $source['id'] ); if ( ! self::can_edit_source( $source['type'], $source['id'] ) ) { $result = array( 'changed' => 0, 'note' => __( 'You are not allowed to edit this item.', 'unbreak' ), ); } elseif ( '' !== $lock ) { // Someone has it open: saving there would silently overwrite our change (and vice versa). $result = array( 'changed' => 0, 'note' => $lock, ); } else { $result = $this->rewriter->process( $source['type'], $source['id'], $source['key'], $source['map'], false ); if ( $result['changed'] > 0 ) { $units[] = $source; } } $desc = LinkQuery::describe_source( $source['type'], $source['id'], $source['key'] ); foreach ( $source['links'] as $link_id => $url ) { $changes[] = array( 'link_id' => $link_id, 'url' => $url, 'new_url' => $source['map'][ Url::normalize( $url ) ?? $url ] ?? null, 'source_type' => $source['type'], 'source_id' => $source['id'], 'source_key' => $source['key'], 'title' => $desc['title'], 'edit_url' => $desc['edit_url'], 'changes' => $result['changed'], 'note' => $result['note'], ); } $total += $result['changed']; if ( 0 === $result['changed'] ) { ++$skipped; } } } elseif ( 'redirect' === $action ) { if ( ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'unbreak_forbidden', __( 'Only administrators can create redirects.', 'unbreak' ), array( 'status' => 403 ) ); } foreach ( $links as $link ) { $source = Url::is_internal( (string) $link->url ) ? Path::normalize( (string) $link->url, true ) : null; $note = ''; if ( null === $source ) { $note = __( 'Only internal links can be redirected.', 'unbreak' ); } elseif ( null !== ( new RedirectRepository() )->find_by_source( $source ) ) { $note = __( 'A redirect for this path already exists.', 'unbreak' ); } $changes[] = array( 'link_id' => (int) $link->id, 'url' => (string) $link->url, 'new_url' => $new_url, 'source_type' => 'redirect', 'source_id' => 0, 'source_key' => (string) $source, 'title' => (string) $source, 'edit_url' => '', 'changes' => '' === $note ? 1 : 0, 'note' => $note, ); $total += '' === $note ? 1 : 0; $skipped += '' === $note ? 0 : 1; if ( '' === $note ) { $units[] = array( 'link_id' => (int) $link->id, 'url' => (string) $link->url, ); } } } else { $total = count( $links ); foreach ( $links as $link ) { $units[] = array( 'link_id' => (int) $link->id, 'url' => (string) $link->url, ); } } $token = wp_generate_password( 20, false ); set_transient( 'unbreak_preview_' . $token, array( 'action' => $action, 'link_ids' => array_map( 'intval', wp_list_pluck( $links, 'id' ) ), 'new_url' => $new_url, 'user' => get_current_user_id(), // The exact plan that was shown: confirm() applies this, not a fresh re-plan. 'units' => $units, ), self::PREVIEW_TTL ); return array( 'token' => $token, 'action' => $action, 'new_url' => $new_url, 'links' => $rows, 'changes' => $changes, 'summary' => array( 'links' => count( $links ), 'sources' => count( array_unique( array_map( static function ( $c ) { return $c['source_type'] . ':' . $c['source_id'] . ':' . $c['source_key']; }, $changes ) ) ), 'changes' => $total, 'skipped' => $skipped, ), ); } /** * Confirm a preview: create a background job. Returns the job. * * @param string $token Preview token. * @return array|\WP_Error */ public function confirm( string $token ) { $key = 'unbreak_preview_' . preg_replace( '/[^A-Za-z0-9]/', '', $token ); $preview = get_transient( $key ); if ( ! is_array( $preview ) || get_current_user_id() !== (int) $preview['user'] ) { return new \WP_Error( 'unbreak_preview_expired', __( 'This preview has expired. Please review the changes again.', 'unbreak' ), array( 'status' => 409 ) ); } delete_transient( $key ); $id = strtolower( wp_generate_password( 12, false ) ); $job = array( 'id' => $id, 'action' => (string) $preview['action'], 'link_ids' => array_map( 'intval', (array) $preview['link_ids'] ), 'new_url' => (string) $preview['new_url'], 'user' => get_current_user_id(), 'batch' => $id, 'status' => 'queued', 'cursor' => 0, 'total' => 0, 'done' => 0, 'changed' => 0, 'skipped' => 0, 'notes' => array(), 'created' => Clock::now(), 'units' => array(), ); $job['units'] = isset( $preview['units'] ) && is_array( $preview['units'] ) ? array_values( $preview['units'] ) : array(); $job['total'] = count( $job['units'] ); $this->save_job( $job ); update_option( 'unbreak_last_job', $id, false ); Jobs::enqueue_unique( self::HOOK, array( $id ) ); return self::public_job( $job ); } /** * Action Scheduler callback: process the job to completion in chunks. * * @param string $id Job ID. */ public function handle_job( $id ): void { $job = $this->step( (string) $id ); if ( null !== $job && 'done' !== $job['status'] ) { Jobs::enqueue_unique( self::HOOK, array( (string) $id ) ); } } /** * Current job state; advances the job by one chunk when nothing else is working on it. * * @param string $id Job ID. * @return array|null */ public function poll( string $id ): ?array { $job = $this->load_job( $id ); if ( null === $job ) { return null; } if ( 'done' !== $job['status'] ) { $job = $this->step( $id ) ?? $job; } return self::public_job( $job ); } /** * Process one chunk of a job under a lock. * * @param string $id Job ID. * @return array|null Updated job. */ public function step( string $id ): ?array { // Take the lock before reading the job, so two workers can never process the same chunk. if ( ! $this->lock( $id ) ) { return $this->load_job( $id ); } $job = $this->load_job( $id ); if ( null === $job || 'done' === $job['status'] ) { $this->unlock( $id ); return $job; } $previous_user = get_current_user_id(); if ( (int) $job['user'] > 0 ) { wp_set_current_user( (int) $job['user'] ); } $job['status'] = 'running'; $end = min( count( $job['units'] ), (int) $job['cursor'] + self::CHUNK ); for ( $i = (int) $job['cursor']; $i < $end; $i++ ) { $this->run_unit( $job, $job['units'][ $i ] ); ++$job['done']; $job['cursor'] = $i + 1; } if ( $job['cursor'] >= count( $job['units'] ) ) { $job['status'] = 'done'; $job['finished'] = Clock::now(); if ( in_array( $job['action'], self::CONTENT_ACTIONS, true ) ) { ( new CheckRunner() )->tick(); } } $this->save_job( $job ); $this->unlock( $id ); wp_set_current_user( $previous_user ); return $job; } /** * Undo the most recent content-changing bulk action. * * @return array{batch:string,restored:int,skipped:int}|\WP_Error */ public function undo_last() { $batch = $this->log->last_batch(); if ( '' === $batch ) { return new \WP_Error( 'unbreak_nothing_to_undo', __( 'There is nothing to undo.', 'unbreak' ), array( 'status' => 404 ) ); } $entries = $this->log->batch( $batch ); // Refuse as a whole (and keep the batch undoable) while any affected post is open in an editor. foreach ( $entries as $entry ) { $lock = self::editor_lock_note( (string) $entry['source_type'], (int) $entry['source_id'] ); if ( '' !== $lock ) { $desc = LinkQuery::describe_source( (string) $entry['source_type'], (int) $entry['source_id'], (string) $entry['source_key'] ); return new \WP_Error( 'unbreak_post_locked', ( '' !== $desc['title'] ? $desc['title'] . ': ' : '' ) . $lock, array( 'status' => 409 ) ); } } $restored = 0; $skipped = 0; foreach ( $entries as $entry ) { if ( $this->rewriter->restore( $entry ) ) { ++$restored; } else { ++$skipped; } } $this->log->forget( $batch ); ( new CheckRunner() )->tick(); return array( 'batch' => $batch, 'restored' => $restored, 'skipped' => $skipped, ); } /** * Validate action input. * * @param string $action Action. * @param int[] $link_ids IDs. * @param string $new_url New URL. * @return array{0:int[],1:string}|\WP_Error */ private function validate( string $action, array $link_ids, string $new_url ) { if ( ! in_array( $action, self::ACTIONS, true ) ) { return new \WP_Error( 'unbreak_bad_action', __( 'Unknown action.', 'unbreak' ), array( 'status' => 400 ) ); } $link_ids = array_values( array_unique( array_filter( array_map( 'intval', $link_ids ) ) ) ); if ( empty( $link_ids ) ) { return new \WP_Error( 'unbreak_no_links', __( 'Select at least one link.', 'unbreak' ), array( 'status' => 400 ) ); } if ( count( $link_ids ) > self::MAX_LINKS ) { /* translators: %d: maximum number of links. */ return new \WP_Error( 'unbreak_too_many', sprintf( __( 'Select at most %d links per action.', 'unbreak' ), self::MAX_LINKS ), array( 'status' => 400 ) ); } if ( 'edit' === $action || 'redirect' === $action ) { $new_url = trim( $new_url ); if ( 'redirect' === $action && '' !== $new_url && '/' === $new_url[0] && ( ! isset( $new_url[1] ) || '/' !== $new_url[1] ) ) { $new_url = home_url( $new_url ); } $new_url = esc_url_raw( $new_url, array( 'http', 'https' ) ); if ( '' === $new_url || null === Url::canonicalize( $new_url ) ) { return new \WP_Error( 'unbreak_bad_url', __( 'Enter a valid http(s) URL.', 'unbreak' ), array( 'status' => 400 ) ); } } else { $new_url = ''; } return array( $link_ids, $new_url ); } /** * Load link rows. * * @param int[] $ids IDs. * @return array */ private function load_links( array $ids ): array { global $wpdb; if ( empty( $ids ) ) { return array(); } $table = Schema::table( 'links' ); $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholders generated above. return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE id IN ({$in}) ORDER BY id ASC", array_merge( array( $table ), $ids ) ) ); } /** * New URL for a link under an action (null = unlink / not applicable). * * @param string $action Action. * @param object $link Link row. * @param string $new_url New URL. */ private function target_for( string $action, object $link, string $new_url ): ?string { switch ( $action ) { case 'edit': case 'redirect': return $new_url; case 'update_final': return ! empty( $link->final_url ) ? (string) $link->final_url : null; } return null; } /** * Group the instances of the selected links by source, with one URL map per source. * * @param array $links Link rows. * @param string $action Action. * @param string $new_url New URL. * @return array,links:array}> */ private function plan_sources( array $links, string $action, string $new_url ): array { global $wpdb; $by_id = array(); foreach ( $links as $link ) { if ( 'update_final' === $action && empty( $link->final_url ) ) { continue; // Nothing to update to. } $by_id[ (int) $link->id ] = $link; } if ( empty( $by_id ) ) { return array(); } $table = Schema::table( 'instances' ); $in = implode( ',', array_fill( 0, count( $by_id ), '%d' ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Placeholders generated above. $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT DISTINCT link_id, source_type, source_id, source_key FROM %i WHERE link_id IN ({$in}) ORDER BY source_type, source_id", array_merge( array( $table ), array_keys( $by_id ) ) ) ); $sources = array(); foreach ( $rows as $row ) { $link = $by_id[ (int) $row->link_id ] ?? null; if ( null === $link ) { continue; } $type = (string) $row->source_type; // Post-based sources are rewritten once per post; widget/comment sources per field. $key = in_array( $type, array( 'widget', 'comment' ), true ) ? (string) $row->source_key : ''; $source_key = $type . ':' . (int) $row->source_id . ':' . $key; if ( ! isset( $sources[ $source_key ] ) ) { $sources[ $source_key ] = array( 'type' => $type, 'id' => (int) $row->source_id, 'key' => $key, 'map' => array(), 'links' => array(), ); } $sources[ $source_key ]['map'][ (string) $link->url ] = $this->target_for( $action, $link, $new_url ); $sources[ $source_key ]['links'][ (int) $link->id ] = (string) $link->url; } return array_values( $sources ); } /** * Execute one unit. * * @param array $job Job (by reference: counters, notes). * @param array $unit Unit. */ private function run_unit( array &$job, array $unit ): void { $repo = new LinkRepository(); switch ( $job['action'] ) { case 'edit': case 'update_final': case 'unlink': if ( ! self::can_edit_source( (string) $unit['type'], (int) $unit['id'] ) ) { ++$job['skipped']; $job['notes'][] = __( 'Skipped an item you are not allowed to edit.', 'unbreak' ); return; } // Checked again at write time: the post may have been opened since the preview. $lock = self::editor_lock_note( (string) $unit['type'], (int) $unit['id'] ); if ( '' !== $lock ) { ++$job['skipped']; if ( count( $job['notes'] ) < 50 ) { $desc = LinkQuery::describe_source( (string) $unit['type'], (int) $unit['id'], (string) $unit['key'] ); $job['notes'][] = ( '' !== $desc['title'] ? $desc['title'] . ': ' : '' ) . $lock; } return; } $result = $this->rewriter->process( (string) $unit['type'], (int) $unit['id'], (string) $unit['key'], (array) $unit['map'], true, (string) $job['batch'], (string) $job['action'] ); $job['changed'] += $result['changed']; if ( 0 === $result['changed'] ) { ++$job['skipped']; if ( '' !== $result['note'] && count( $job['notes'] ) < 50 ) { $desc = LinkQuery::describe_source( (string) $unit['type'], (int) $unit['id'], (string) $unit['key'] ); $job['notes'][] = ( '' !== $desc['title'] ? $desc['title'] . ': ' : '' ) . $result['note']; } } return; case 'dismiss': case 'undismiss': $job['changed'] += $repo->set_dismissed( array( (int) $unit['link_id'] ), 'dismiss' === $job['action'] ); return; case 'recheck': $job['changed'] += $repo->mark_due( array( (int) $unit['link_id'] ) ); ( new CheckRunner() )->tick(); return; case 'redirect': $saved = ( new RedirectRepository() )->save( array( 'source_path' => (string) $unit['url'], 'target_url' => (string) $job['new_url'], 'code' => 301, 'match_mode' => '404', ) ); if ( is_wp_error( $saved ) ) { ++$job['skipped']; $job['notes'][] = $unit['url'] . ': ' . $saved->get_error_message(); } else { ++$job['changed']; } return; } } /** * Whether the current user may edit a content source (core capabilities, not just the plugin's). * * @param string $type Source type. * @param int $id Source ID. */ public static function can_edit_source( string $type, int $id ): bool { switch ( $type ) { case 'post_content': case 'elementor': return current_user_can( 'edit_post', $id ); case 'menu': case 'widget': return current_user_can( 'edit_theme_options' ); case 'comment': return current_user_can( 'edit_comment', $id ); } return false; } /** * Why a source must not be rewritten right now: another user has the post open in the block, * classic or Elementor editor (core post lock). Returns '' when it is free. * * @param string $type Source type. * @param int $id Source ID. */ public static function editor_lock_note( string $type, int $id ): string { if ( 'post_content' !== $type && 'elementor' !== $type ) { return ''; } if ( ! function_exists( 'wp_check_post_lock' ) ) { require_once ABSPATH . 'wp-admin/includes/post.php'; } $user_id = wp_check_post_lock( $id ); if ( false === $user_id ) { return ''; } $user = get_userdata( (int) $user_id ); $name = $user instanceof \WP_User ? $user->display_name : __( 'another user', 'unbreak' ); /* translators: %s: name of the user who has the post open. */ return sprintf( __( 'Open in the editor by %s — close it and re-run', 'unbreak' ), $name ); } /** * Load a job. * * @param string $id Job ID. * @return array|null */ public function load_job( string $id ): ?array { $id = preg_replace( '/[^a-z0-9]/', '', strtolower( $id ) ); $job = '' !== $id ? get_option( 'unbreak_job_' . $id ) : false; return is_array( $job ) ? $job : null; } /** * Save a job (non-autoloaded option; removed after a day by maintenance). * * @param array $job Job. */ private function save_job( array $job ): void { update_option( 'unbreak_job_' . $job['id'], $job, false ); } /** * Acquire the job lock (atomic add_option); stale locks expire after LOCK_TTL seconds. * * @param string $id Job ID. */ private function lock( string $id ): bool { $name = 'unbreak_joblock_' . $id; if ( add_option( $name, Clock::now(), '', false ) ) { return true; } $since = (int) get_option( $name, 0 ); if ( Clock::now() - $since > self::LOCK_TTL ) { update_option( $name, Clock::now(), false ); return true; } return false; } /** * Release the job lock. * * @param string $id Job ID. */ private function unlock( string $id ): void { delete_option( 'unbreak_joblock_' . $id ); } /** * Job without internal planning data. * * @param array $job Job. * @return array */ public static function public_job( array $job ): array { unset( $job['units'], $job['link_ids'], $job['user'] ); $job['percent'] = $job['total'] > 0 ? (int) floor( 100 * $job['done'] / $job['total'] ) : 100; return $job; } /** * Delete finished jobs older than a day (called by maintenance). */ public static function cleanup(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Maintenance lookup of our own options. $names = (array) $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'unbreak_job_' ) . '%' ) ); foreach ( $names as $name ) { $job = get_option( $name ); if ( ! is_array( $job ) || ( (int) ( $job['created'] ?? 0 ) < Clock::now() - DAY_IN_SECONDS ) ) { delete_option( $name ); } } } /** * Statuses whose links can be re-checked in bulk from the dashboard ("Re-check all broken"). * * @return string[] */ public static function recheckable_statuses(): array { return array_merge( StatusPolicy::problem_statuses(), array( StatusPolicy::UNCONFIRMED ) ); } }