as3cf = $as3cf; $this->running_update_text = $this->get_running_update_text(); $this->cron_hook = 'as3cf_cron_update_' . $this->upgrade_name; $this->cron_schedule_key = 'as3cf_update_' . $this->upgrade_name . '_interval'; $this->cron_interval_in_minutes = apply_filters( 'as3cf_update_' . $this->upgrade_name . '_interval', 2 ); $this->error_threshold = apply_filters( 'as3cf_update_' . $this->upgrade_name . '_error_threshold', 20 ); $this->max_items_processable = apply_filters( 'as3cf_update_' . $this->upgrade_name . '_batch_size', $this->size_limit ); if ( $this->is_completed() ) { return; } add_filter( 'cron_schedules', array( $this, 'cron_schedules' ) ); // phpcs:ignore WordPress.WP.CronInterval add_action( $this->cron_hook, array( $this, 'do_upgrade' ) ); add_action( 'admin_init', array( $this, 'maybe_handle_action' ) ); // Each upgrade potentially has a unique settings locked notification. add_filter( 'as3cf_get_upgrade_locked_notifications', array( $this, 'get_locked_notifications' ) ); add_filter( 'as3cf_get_running_upgrade', array( $this, 'get_running_upgrade' ) ); // Do default checks if the upgrade can be started if ( $this->maybe_init() ) { $this->init(); } } /** * Can we start the upgrade using default checks * * @return bool */ protected function maybe_init() { if ( AS3CF_Utils::is_ajax() ) { return false; } if ( ! $this->screen_can_init() ) { return false; } if ( ! $this->as3cf->is_plugin_setup( true ) ) { return false; } if ( $this->is_completed() ) { return false; } if ( ! $this->has_previous_upgrade_completed() ) { return false; } if ( $this->is_locked() ) { return false; } // If the upgrade status is already set, then we've already initialized the upgrade if ( $this->get_upgrade_status() ) { if ( $this->is_running() ) { // Make sure cron job is persisted in case it has dropped $this->schedule(); } else { // Refresh the lock to stop anything from interfering while paused. $this->lock_upgrade(); } return false; } return true; } /** * Count items left to process for the current blog. * * @return int */ protected function count_items_to_process() { return count( $this->get_items_to_process( $this->blog_prefix, false, $this->last_item ) ); } /** * Get items to process. * * @param string $prefix * @param int $limit * @param bool|mixed $offset * * @return array */ abstract protected function get_items_to_process( $prefix, $limit, $offset = false ); /** * Upgrade item. * * @param mixed $item * * @return bool */ abstract protected function upgrade_item( $item ); /** * Get running update text. * * @return string */ abstract protected function get_running_update_text(); /** * Fire up the upgrade */ protected function init() { // Initialize the upgrade $this->save_session( array( 'status' => self::STATUS_RUNNING ) ); $this->schedule(); } /** * WP Cron callback to run the upgrade. */ public function do_upgrade() { $this->lock_upgrade(); $this->start_timer(); if ( $this->is_completed() || ! $this->is_running() ) { $this->unschedule(); return; } $this->boot_session(); $this->run_upgrade(); } /** * Run or resume the main upgrade process. */ protected function run_upgrade() { try { $blog_id = $this->get_initial_blog_id(); do { $this->switch_to_blog( $blog_id ); $this->check_batch_limits(); if ( $this->upgrade_blog() ) { $this->blog_upgrade_completed(); } else { // If the blog didn't complete, break and try again next time before moving on. break; } } while ( $blog_id = $this->next_blog_id() ); // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition } catch ( No_More_Blogs_Exception $e ) { /* * The upgrade is complete when there are no more blogs left to finish. */ $this->upgrade_finished(); return; } catch ( Too_Many_Errors_Exception $e ) { $this->upgrade_error(); return; } catch ( Batch_Limits_Exceeded_Exception $e ) { //phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Save the session and finish this round right away. } $this->close_session(); $this->save_session( $this->session ); } /** * Upgrade the current blog. * * @return bool true if all items for the blog were upgraded, otherwise false. * @throws Batch_Limits_Exceeded_Exception * * @throws Too_Many_Errors_Exception */ protected function upgrade_blog() { $total = $this->count_items_to_process(); $items = $this->blog_batch_items(); $upgraded = 0; foreach ( $items as $item ) { if ( $this->upgrade_item( $item ) ) { $this->item_upgrade_completed( $item ); $upgraded++; } // Items always count towards processing limits. $this->items_processed++; $this->check_batch_limits(); } /* * If the number upgraded is the same as the remaining total to process * then all items have been upgraded for this blog. */ if ( $upgraded === (int) $total ) { return true; } return false; } /** * Get the next sequential blog ID if there is one. * * @return int * @throws No_More_Blogs_Exception */ protected function next_blog_id() { $blog_id = $this->blog_id ? $this->blog_id : $this->last_blog_id; do { $blog_id--; if ( $blog_id < 1 ) { throw new No_More_Blogs_Exception(); } } while ( ! $this->is_blog_processable( $blog_id ) ); return $blog_id; } /** * Get the maximum number of processable items for the current blog, * limited by the remaining number of items possible to process for this request. * * @return array */ protected function blog_batch_items() { $limit = $this->max_items_processable - $this->items_processed; return $this->get_items_to_process( $this->blog_prefix, $limit, $this->last_item ); } /** * Adds notices about issues with upgrades allowing user to restart them. */ public function maybe_display_notices() { $action_url = $this->as3cf->get_plugin_page_url( array( 'action' => 'restart_update', 'update' => $this->upgrade_name, ), 'self' ); $msg_type = 'notice-info'; $custom_id = 'as3cf-upgrade-notice-' . $this->upgrade_name; switch ( $this->get_upgrade_status() ) { case self::STATUS_RUNNING: $msg = $this->get_running_message(); $action_text = __( 'Pause Update', 'amazon-s3-and-cloudfront' ); $action_url = $this->as3cf->get_plugin_page_url( array( 'action' => 'pause_update', 'update' => $this->upgrade_name, ), 'self' ); break; case self::STATUS_PAUSED: $msg = $this->get_paused_message(); $action_text = __( 'Restart Update', 'amazon-s3-and-cloudfront' ); break; case self::STATUS_ERROR: $msg = $this->get_error_message(); $action_text = __( 'Try To Run It Again', 'amazon-s3-and-cloudfront' ); $msg_type = 'error'; break; default: $this->as3cf->notices->remove_notice_by_id( $custom_id ); return; } $msg .= ' ' . $action_text . ''; $args = array( 'custom_id' => $custom_id, 'type' => $msg_type, 'dismissible' => false, 'only_show_in_settings' => true, ); $this->as3cf->notices->add_notice( $msg, $args ); } /** * Get running message. * * @return string */ protected function get_running_message() { return sprintf( __( 'Running %1$s Update%2$s — We’re going through all the offloaded Media Library items %3$s This will be done quietly in the background, processing a small batch of Media Library items every %4$d minutes. There should be no noticeable impact on your server’s performance.', 'amazon-s3-and-cloudfront' ), ucwords( $this->upgrade_type ), $this->get_progress_text(), $this->running_update_text, $this->cron_interval_in_minutes ); } /** * Get paused message. * * @return string */ protected function get_paused_message() { return sprintf( __( '%1$s Update Paused%2$s — Updating Media Library %3$s has been paused.', 'amazon-s3-and-cloudfront' ), ucwords( $this->upgrade_type ), $this->get_progress_text(), $this->upgrade_type ); } /** * Get error message. * * @return string */ protected function get_error_message() { return sprintf( __( 'Error Updating %1$s — We ran into some errors attempting to update the %2$s for all your Media Library items that have been offloaded. Please check your error log for details. (#%3$d)', 'amazon-s3-and-cloudfront' ), ucwords( $this->upgrade_type ), $this->upgrade_type, $this->upgrade_id ); } /** * Get progress text. * * @return string */ protected function get_progress_text() { $progress = $this->calculate_progress(); if ( false === $progress ) { // Progress can not be calculated, return return ''; } if ( $progress > 100 ) { $progress = 100; } return sprintf( __( ' (%s%% Complete)', 'amazon-s3-and-cloudfront' ), $progress ); } /** * Calculate progress. * * @return bool|float * @throws Batch_Limits_Exceeded_Exception */ protected function calculate_progress() { $this->boot_session(); if ( is_multisite() ) { $all_blog_ids = AS3CF_Utils::get_blog_ids(); $decimal = count( $this->processed_blogs_ids ) / count( $all_blog_ids ); } else { // Set up any per-site state $this->switch_to_blog( get_current_blog_id() ); $counts = Media_Library_Item::count_items(); // If there are no items, disable progress calculation // and protect against division by zero. if ( ! $counts['total'] ) { return false; } $remaining = $this->count_items_to_process(); $decimal = ( $counts['total'] - $remaining ) / $counts['total']; } return round( $decimal * 100, 2 ); } /** * Handler for the running upgrade actions */ public function maybe_handle_action() { if ( ! isset( $_GET['page'] ) || sanitize_key( $_GET['page'] ) !== $this->as3cf->get_plugin_slug() ) { // input var okay return; } if ( ! isset( $_GET['action'] ) ) { return; } if ( ! isset( $_GET['update'] ) || sanitize_key( $_GET['update'] ) !== $this->upgrade_name ) { // input var okay return; } $method_name = 'action_' . sanitize_key( $_GET['action'] ); // input var okay if ( method_exists( $this, $method_name ) ) { call_user_func( array( $this, $method_name ) ); } } /** * Exit upgrade with an error */ protected function upgrade_error() { $this->close_session(); $this->session['status'] = self::STATUS_ERROR; $this->save_session( $this->session ); $this->unschedule(); } /** * Complete the upgrade */ protected function upgrade_finished() { $this->clear_session(); $this->update_saved_upgrade_id(); $this->unlock_upgrade(); $this->unschedule(); } /** * Restart upgrade */ protected function action_restart_update() { $this->init(); $this->end_action(); } /** * Pause upgrade */ protected function action_pause_update() { $this->unschedule(); if ( $this->is_running() ) { $this->change_status_request( self::STATUS_PAUSED ); } $this->end_action(); } /** * Common function for ending an action in a consistent way. */ private function end_action() { // Make sure notices reflect new status. $this->maybe_display_notices(); $url = $this->as3cf->get_plugin_page_url( array(), 'self' ); wp_redirect( $url ); exit; } /** * Helper for the above action requests * * @param int $status */ protected function change_status_request( $status ) { $session = $this->get_session(); $session['status'] = $status; $this->save_session( $session ); } /** * Schedule the cron */ protected function schedule() { $this->as3cf->schedule_event( $this->cron_hook, $this->cron_schedule_key ); } /** * Remove the cron schedule */ protected function unschedule() { $this->as3cf->clear_scheduled_event( $this->cron_hook ); } /** * Add custom cron interval schedules * * @param array $schedules * * @return array */ public function cron_schedules( $schedules ) { // Add the upgrade interval to the existing schedules. $schedules[ $this->cron_schedule_key ] = array( 'interval' => $this->cron_interval_in_minutes * 60, 'display' => sprintf( __( 'Every %d Minutes', 'amazon-s3-and-cloudfront' ), $this->cron_interval_in_minutes ), ); return $schedules; } /** * Get the current status of the upgrade * See STATUS_* constants in the class declaration above. */ protected function get_upgrade_status() { $session = $this->get_session(); if ( ! isset( $session['status'] ) ) { return ''; } return $session['status']; } /** * Retrieve session data from plugin settings * * @return array */ protected function get_session() { return get_site_option( 'as3cf_update_' . $this->upgrade_name . '_session', array() ); } /** * Store data to be used between requests in plugin settings * * @param array $session session data to store */ protected function save_session( $session ) { update_site_option( 'as3cf_update_' . $this->upgrade_name . '_session', $session ); } /** * Remove the session data to be used between requests */ protected function clear_session() { delete_site_option( 'as3cf_update_' . $this->upgrade_name . '_session' ); } /** * Get the saved upgrade ID * * @return int|mixed|string|WP_Error */ protected function get_saved_upgrade_id() { return $this->as3cf->get_setting( $this->settings_key, 0 ); } /** * Update the saved upgrade ID */ protected function update_saved_upgrade_id() { $this->as3cf->set_setting( $this->settings_key, $this->upgrade_id ); $this->as3cf->save_settings(); } /** * Has previous upgrade completed * * @return bool */ protected function has_previous_upgrade_completed() { // Has the previous upgrade completed yet? $previous_id = $this->upgrade_id - 1; if ( 0 !== $previous_id && (int) $this->get_saved_upgrade_id() < $previous_id ) { // Previous still running, abort return false; } return true; } /** * Lock upgrade. */ protected function lock_upgrade() { set_site_transient( static::$lock_key, $this->upgrade_id, MINUTE_IN_SECONDS * 3 ); } /** * Unlock the upgrade. * * Voids the lock after 1 second rather than deleting to avoid a race condition. */ protected function unlock_upgrade() { set_site_transient( static::$lock_key, $this->upgrade_id, 1 ); } /** * Whether or not the upgrade lock has been set. * * @return bool */ public static function is_locked() { return false !== get_site_transient( static::$lock_key ); } /** * Whether this upgrade has been completed or not. * * @return bool */ protected function is_completed() { return $this->get_saved_upgrade_id() >= $this->upgrade_id; } /** * Whether this upgrade is currently running or not. * * @return bool */ protected function is_running() { return self::STATUS_RUNNING === $this->get_upgrade_status(); } /** * Whether this upgrade is currently paused or not. * * @return bool */ protected function is_paused() { return self::STATUS_PAUSED === $this->get_upgrade_status(); } /** * Set the time when the upgrade must finish by. */ protected function start_timer() { $this->finish = time() + apply_filters( 'as3cf_update_' . $this->upgrade_name . '_time_limit', $this->time_limit ); } /** * Check to see if batch limits have been exceeded. * * @throws Batch_Limits_Exceeded_Exception * @throws Too_Many_Errors_Exception */ protected function check_batch_limits() { if ( $this->error_count > $this->error_threshold ) { throw new Too_Many_Errors_Exception(); } if ( $this->items_processed > $this->max_items_processable ) { throw new Batch_Limits_Exceeded_Exception( 'Item limit reached.' ); } if ( time() > $this->finish ) { throw new Batch_Limits_Exceeded_Exception( 'Time limit exceeded.' ); } if ( $this->as3cf->memory_exceeded( 'as3cf_update_' . $this->upgrade_name . '_memory_exceeded' ) ) { throw new Batch_Limits_Exceeded_Exception( 'Memory limit exceeded with ' . memory_get_usage( true ) / 1024 / 1024 . 'MB' ); } } /** * Check if a blog exists for the given blog ID. * * @param int $blog_id * * @return bool */ protected function blog_exists( $blog_id ) { static $all_ids; if ( function_exists( 'get_site' ) ) { return (bool) get_site( $blog_id ); } if ( ! $all_ids ) { $all_ids = AS3CF_Utils::get_blog_ids(); } return in_array( $blog_id, $all_ids ); } /** * Get the largest blog ID on the network. * * @return null|string */ protected function get_final_blog_id() { global $wpdb; if ( is_multisite() ) { return $wpdb->get_var( "SELECT MAX(blog_id) FROM {$wpdb->blogs}" ); } return 1; } /** * Get the initial blog ID to start iterating with. * * @return int */ protected function get_initial_blog_id() { if ( $this->last_blog_id ) { return $this->next_blog_id(); } return (int) $this->get_final_blog_id(); } /** * Whether the given blog ID is processable or not. * * @param int $blog_id * * @return bool */ protected function is_blog_processable( $blog_id ) { if ( in_array( $blog_id, $this->processed_blogs_ids ) ) { return false; } return $this->blog_exists( $blog_id ); } /** * Populate the session properties from the saved state. */ protected function boot_session() { $this->session = $this->get_session(); $this->last_blog_id = $this->load_last_blog_id(); $this->processed_blogs_ids = $this->load_processesed_blog_ids(); $this->error_count = isset( $this->session['error_count'] ) ? $this->session['error_count'] : 0; $this->last_item = $this->load_last_item(); } /** * Get all of the processed blog IDs from the session. * * @return array */ protected function load_processesed_blog_ids() { $session = $this->session ? $this->session : $this->get_session(); return isset( $session['processed_blog_ids'] ) ? $session['processed_blog_ids'] : array(); } /** * Mark the current blog upgrade as complete. */ protected function blog_upgrade_completed() { $this->last_blog_id = $this->blog_id; $this->processed_blogs_ids[] = $this->blog_id; $this->last_item = false; } /** * Perform any actions necessary after the given item is completed. * * @param mixed $item */ protected function item_upgrade_completed( $item ) { $this->last_item = $item; } /** * Prepare the session to be persisted. */ protected function close_session() { $this->session['last_blog_id'] = $this->last_blog_id; $this->session['offset'] = $this->last_item; $this->session['error_count'] = $this->error_count; $this->session['processed_blog_ids'] = $this->processed_blogs_ids; } /** * Load the last completed blog ID from the session. * * @return bool|int */ protected function load_last_blog_id() { if ( ! empty( $this->session['last_blog_id'] ) ) { return (int) $this->session['last_blog_id']; } return null; } /** * Switch to the given blog, and update blog-specific state. * * @param int $blog_id * * @throws Batch_Limits_Exceeded_Exception */ protected function switch_to_blog( $blog_id ) { $this->as3cf->switch_to_blog( $blog_id ); $this->blog_id = (int) $blog_id; $this->blog_prefix = $GLOBALS['wpdb']->prefix; } /** * Get the last processed item from the session. * * @return bool|mixed */ protected function load_last_item() { return isset( $this->session['offset'] ) ? $this->session['offset'] : false; } /** * Whether or not the current screen can initialize the upgrade. * * @return bool */ protected function screen_can_init() { if ( is_multisite() ) { return is_network_admin(); } return is_admin(); } /** * Get description for locked notification. * * @return string */ public function get_locked_notification() { return sprintf( __( 'Settings Locked — You can\'t change any of your settings until the "%s" update has completed.', 'amazon-s3-and-cloudfront' ), ucwords( $this->upgrade_type ) ); } /** * Append this upgrade's locked notification text to an array, and maybe show the upgrade status notice. * * @handles as3cf_get_upgrade_locked_notifications * * @param array $notifications * * @return array */ public function get_locked_notifications( array $notifications ) { $this->maybe_display_notices(); $notifications[ $this->upgrade_name ] = $this->get_locked_notification(); return $notifications; } /** * Returns upgrade's name if it is running, paused or locked (usually due to errors if not running or paused). * * @handles as3cf_get_running_upgrade * * @param string $running_upgrade * * @return string */ public function get_running_upgrade( $running_upgrade ) { if ( empty( $running_upgrade ) && ( $this->is_running() || $this->is_paused() || $this->is_locked() ) ) { return $this->upgrade_name; } return $running_upgrade; } }