response ) ) ? (array) $updates->response : array(); foreach ( get_plugins() as $file => $data ) { $dir = dirname( $file ); $alone = ( '.' === $dir ); $slug = $alone ? basename( $file, '.php' ) : $dir; $out[] = array( 'id' => 'plugin:' . $file, 'type' => 'plugin', 'slug' => $slug, 'name' => '' !== (string) $data['Name'] ? (string) $data['Name'] : $slug, 'version' => (string) $data['Version'], 'path' => WP_PLUGIN_DIR . '/' . ( $alone ? $file : $dir ), 'active' => in_array( $file, $active, true ) || in_array( $file, $network, true ), 'update_available' => self::new_version( isset( $pending[ $file ] ) ? $pending[ $file ] : null ), ); } foreach ( get_mu_plugins() as $file => $data ) { $out[] = array( 'id' => 'mu-plugin:' . $file, 'type' => 'mu-plugin', 'slug' => basename( $file, '.php' ), 'name' => '' !== (string) $data['Name'] ? (string) $data['Name'] : $file, 'version' => (string) $data['Version'], 'path' => WPMU_PLUGIN_DIR . '/' . $file, 'active' => true, 'update_available' => '', ); } $updates = get_site_transient( 'update_themes' ); $pending = ( is_object( $updates ) && ! empty( $updates->response ) ) ? (array) $updates->response : array(); foreach ( wp_get_themes() as $stylesheet => $theme ) { $name = (string) $theme->get( 'Name' ); $out[] = array( 'id' => 'theme:' . $stylesheet, 'type' => 'theme', 'slug' => (string) $stylesheet, 'name' => '' !== $name ? $name : (string) $stylesheet, 'version' => (string) $theme->get( 'Version' ), 'path' => $theme->get_stylesheet_directory(), 'active' => get_stylesheet() === (string) $stylesheet || get_template() === (string) $stylesheet, 'update_available' => self::new_version( isset( $pending[ $stylesheet ] ) ? $pending[ $stylesheet ] : null ), ); } $order = array( 'plugin' => 0, 'mu-plugin' => 1, 'theme' => 2, ); usort( $out, static function ( $a, $b ) use ( $order ) { if ( $a['type'] !== $b['type'] ) { return $order[ $a['type'] ] - $order[ $b['type'] ]; } return strnatcasecmp( $a['name'], $b['name'] ); } ); return $out; } /** * Start a scan. An empty list means everything; a list is validated against targets(). * * A list is merged into the previous report when the scan ends, unless $replace is true. * A scan that is still running is discarded. * * @param string[] $target_ids Target ids from targets(). * @param bool $replace Replace the whole report with this scan. * @return array The public state. status 'error' (not saved) when nothing valid was asked for. */ public static function start( array $target_ids = array(), $replace = false ) { $by_id = array(); foreach ( self::targets() as $target ) { $by_id[ $target['id'] ] = $target; } $wanted = array_keys( $by_id ); if ( ! empty( $target_ids ) ) { $wanted = array(); foreach ( $target_ids as $id ) { if ( is_string( $id ) && isset( $by_id[ $id ] ) && ! in_array( $id, $wanted, true ) ) { $wanted[] = $id; } } if ( empty( $wanted ) ) { return self::fail( __( 'That plugin or theme is not installed any more. Reload the page.', 'hexloom-php-version-scan' ) ); } } $old = self::load(); if ( null !== $old && 'running' === $old['status'] ) { self::drop_work( $old ); } $queue = array(); $room = self::MAX_FILES; $beyond = 0; foreach ( $wanted as $id ) { $target = $by_id[ $id ]; $found = self::count_files( $target['path'] ); $take = min( $found, $room ); $room -= $take; $beyond += $found - $take; $target['n'] = $found; $target['lim'] = $take; $queue[] = $target; } $message = ''; if ( $beyond > 0 ) { /* translators: %s: the largest number of PHP files one scan covers, for example 60,000 */ $message = sprintf( __( 'This site has more PHP files than one scan covers. The first %s are scanned; the rest are listed as not scanned.', 'hexloom-php-version-scan' ), number_format_i18n( self::MAX_FILES ) ); } $state = array( 'id' => strtolower( wp_generate_password( 10, false, false ) ), 'status' => 'running', 'partial' => ! empty( $target_ids ) && ! $replace, 'started' => time(), 'finished' => 0, 'targets_total' => count( $queue ), 'targets_done' => 0, 'files_total' => self::MAX_FILES - $room, 'files_done' => 0, 't' => 0, 'last' => '', 'current' => '', 'message' => $message, 'lock' => 0, 'inflight' => array(), ); self::write( self::OPT_QUEUE, array( 'q' => $queue ) ); self::save( $state ); return self::view( $state ); } /** * Scan files until the time budget is spent, then save and return the state. * * @param float $budget_seconds Wall-clock budget for this request. * @param string $scan_id The scan the caller believes it is driving ('' = any). * @return array The public state, plus 'busy' => true when another request holds the step lock. */ public static function step( $budget_seconds = 3.0, $scan_id = '' ) { $now = microtime( true ); $state = self::load(); if ( null === $state ) { return self::fail( __( 'There is no scan to continue. Start a new one.', 'hexloom-php-version-scan' ) ); } if ( '' !== $scan_id && $scan_id !== $state['id'] ) { return self::fail( __( 'This scan was replaced by a newer one, possibly in another tab. Reload the page to follow it.', 'hexloom-php-version-scan' ) ); } if ( 'running' !== $state['status'] ) { return self::view( $state ); } if ( (float) $state['lock'] > $now ) { $view = self::view( $state ); $view['busy'] = true; return $view; } $queue = self::read( self::OPT_QUEUE ); if ( null === $queue || ! isset( $queue['q'] ) || ! is_array( $queue['q'] ) ) { return self::halt( $state, __( 'The list of what to scan was lost. Start a new scan.', 'hexloom-php-version-scan' ) ); } $q = $queue['q']; $state['lock'] = $now + self::LOCK_SECONDS; self::save( $state ); self::$held = true; if ( ! self::$registered ) { self::$registered = true; register_shutdown_function( array( __CLASS__, 'release' ) ); } $deadline = $now + self::budget( $budget_seconds ); $total = count( $q ); $ctx = null; $first = true; $recheck = true; while ( (int) $state['t'] < $total ) { if ( ! $first && microtime( true ) >= $deadline ) { break; } $first = false; if ( null === $ctx ) { $ctx = self::open( $state, $q ); } if ( $recheck ) { $recheck = false; self::recover( $state, $ctx, $deadline ); } $batch = array_slice( $ctx['pending'], 0, self::BATCH_FILES ); if ( empty( $batch ) ) { self::close( $state, $ctx ); $ctx = null; continue; } if ( empty( $state['inflight'] ) ) { $state['inflight'] = array( 'files' => $batch ); self::save( $state ); } self::run_files( $state, $ctx, $batch, $deadline, false ); // Another tab may have cancelled, reset or restarted the scan while this request was working: then write nothing. $fresh = self::fresh_state(); if ( null === $fresh || $fresh['id'] !== $state['id'] || 'running' !== $fresh['status'] ) { self::$held = false; if ( null === $fresh || $fresh['id'] !== $state['id'] || 'done' !== $fresh['status'] ) { self::remove( self::row_name( $state['id'], $state['t'] ) ); } return null === $fresh ? self::fail( __( 'The scan was removed.', 'hexloom-php-version-scan' ) ) : self::view( $fresh ); } $ctx['pending'] = self::after( $ctx['pending'], $state['last'] ); $next = array_slice( $ctx['pending'], 0, self::BATCH_FILES ); $state['inflight'] = empty( $next ) ? array() : array( 'files' => $next ); self::save_row( $state, $ctx ); self::save( $state ); } if ( (int) $state['t'] >= $total ) { self::finish( $state, $q ); } $state['inflight'] = array(); $state['lock'] = 0; self::$held = false; self::save( $state ); if ( 'done' === $state['status'] ) { do_action( 'hxpc_scan_finished', self::report() ); } return self::view( $state ); } /** * Stop a running scan and throw its partial results away. The previous report stays. * * @return array|null The public state, or null when no scan exists. */ public static function cancel() { $state = self::load(); if ( null === $state ) { return null; } if ( 'running' === $state['status'] ) { self::drop_work( $state ); $state['status'] = 'cancelled'; $state['lock'] = 0; $state['inflight'] = array(); $state['current'] = ''; self::save( $state ); } return self::view( $state ); } /** * The public state of the latest scan, or null when none exists. * * @return array|null */ public static function state() { $state = self::load(); return null === $state ? null : self::view( $state ); } /** * The last finished report: one row per target. * * @return array[]|null Rows, or null when no scan has finished. */ public static function report() { $index = self::read( self::OPT_REPORT ); if ( null === $index || ! isset( $index['rows'] ) || ! is_array( $index['rows'] ) ) { return null; } $rows = array(); foreach ( $index['rows'] as $name ) { $row = self::read( (string) $name ); if ( null !== $row ) { $rows[] = $row; } } return $rows; } /** * Delete the state, the report and every stored result. * * @return void */ public static function reset() { $state = self::load(); if ( null !== $state && 'running' === $state['status'] ) { self::drop_work( $state ); } $index = self::read( self::OPT_REPORT ); if ( null !== $index && isset( $index['rows'] ) && is_array( $index['rows'] ) ) { foreach ( $index['rows'] as $name ) { self::remove( (string) $name ); } } self::remove( self::OPT_REPORT ); self::remove( self::OPT_QUEUE ); self::remove( self::OPT_STATE ); } /** * Shutdown handler: frees the step lock when a request dies, so the next step need not wait. * * @internal * @return void */ public static function release() { if ( ! self::$held ) { return; } self::$held = false; $state = self::load(); if ( null !== $state ) { $state['lock'] = 0; self::save( $state ); } } /** * Largest time one request may spend: the budget, or 40 % of a short max_execution_time. * * @param float $requested The wanted budget in seconds. * @return float */ private static function budget( $requested ) { $budget = max( 0.01, (float) $requested ); $limit = (int) ini_get( 'max_execution_time' ); if ( $limit > 0 && $limit < 30 ) { $budget = min( $budget, $limit * 0.4 ); } return $budget; } /** * Open the current target: load or create its result row, list what is left to scan. * * @param array $state The state (current label is updated). * @param array $q The queue. * @return array Context: row, root, single (bool), pending (relative paths still to scan). */ private static function open( array &$state, array $q ) { $index = (int) $state['t']; $t = $q[ $index ]; $row = self::read( self::row_name( $state['id'], $index ) ); $new = ( null === $row ); if ( $new ) { $row = self::new_row( $t ); } $state['current'] = (string) $t['name']; $ctx = array( 'row' => $row, 'root' => (string) $t['path'], 'single' => false, 'pending' => array(), ); $reason = ''; if ( ! file_exists( $ctx['root'] ) ) { $reason = __( 'The folder was not found.', 'hexloom-php-version-scan' ); } elseif ( is_link( $ctx['root'] ) ) { $reason = __( 'The folder is a symbolic link, and links are not followed.', 'hexloom-php-version-scan' ); } elseif ( ! self::allowed( $ctx['root'] ) ) { $reason = __( 'The folder is outside the plugin and theme folders.', 'hexloom-php-version-scan' ); } if ( '' !== $reason ) { if ( $new ) { self::add_skip( $ctx['row'], '', $reason ); } } else { $ctx['single'] = is_file( $ctx['root'] ); $files = array_slice( self::list_files( $ctx['root'] ), 0, (int) $t['lim'] ); if ( $new && isset( $t['n'] ) ) { $ctx['row']['unscanned'] = max( 0, (int) $t['n'] - (int) $t['lim'] ); } $ctx['pending'] = self::after( $files, (string) $state['last'] ); } if ( $new ) { self::save_row( $state, $ctx ); } return $ctx; } /** * Handle a marker left by a request that died: re-run its files one at a time. * * A 'files' marker means the batch was running: scan those files again, one by one, each * with its own marker. A 'single' marker means that one file killed the previous request: * record it as skipped and move on. * * @param array $state The state. * @param array $ctx The open target. * @param float $deadline Request deadline (microtime). * @return void */ private static function recover( array &$state, array &$ctx, $deadline ) { $mark = isset( $state['inflight'] ) && is_array( $state['inflight'] ) ? $state['inflight'] : array(); if ( empty( $mark ) ) { return; } $state['inflight'] = array(); if ( isset( $mark['single'] ) && is_string( $mark['single'] ) ) { if ( strcmp( $mark['single'], (string) $state['last'] ) > 0 ) { self::add_skip( $ctx['row'], $mark['single'], __( 'the scan stopped while reading this file', 'hexloom-php-version-scan' ) ); $state['last'] = $mark['single']; ++$state['files_done']; } } elseif ( isset( $mark['files'] ) && is_array( $mark['files'] ) ) { $files = array(); foreach ( $mark['files'] as $rel ) { if ( is_string( $rel ) && in_array( $rel, $ctx['pending'], true ) ) { $files[] = $rel; } } self::run_files( $state, $ctx, $files, $deadline, true ); } $ctx['pending'] = self::after( $ctx['pending'], (string) $state['last'] ); self::save_row( $state, $ctx ); self::save( $state ); } /** * Scan the given files in order until the deadline; always at least one. * * @param array $state The state (cursor and counters move). * @param array $ctx The open target (its row collects the results). * @param array $files Relative paths, in scan order. * @param float $deadline Request deadline (microtime). * @param bool $careful Save a marker before every file and the result after it. * @return int Files handled. */ private static function run_files( array &$state, array &$ctx, array $files, $deadline, $careful ) { $done = 0; foreach ( $files as $rel ) { if ( $done > 0 && microtime( true ) >= $deadline ) { break; } if ( $careful ) { $state['inflight'] = array( 'single' => $rel ); self::save( $state ); } $abs = $ctx['single'] ? $ctx['root'] : $ctx['root'] . '/' . $rel; self::add_result( $ctx['row'], $rel, self::scan_one( $abs ) ); $state['last'] = $rel; ++$state['files_done']; ++$done; if ( $careful ) { self::save_row( $state, $ctx ); } } return $done; } /** * Scan one file through the engine. Never throws. * * @param string $abs Absolute path. * @return array findings and skipped, as Hxpc_Scanner::scan_file() returns them. */ private static function scan_one( $abs ) { if ( is_link( $abs ) || ! is_file( $abs ) ) { return array( 'findings' => array(), 'skipped' => __( 'could not be read', 'hexloom-php-version-scan' ), ); } try { $res = Hxpc_Scanner::scan_file( $abs ); } catch ( Throwable $e ) { $res = array( 'findings' => array(), 'skipped' => __( 'could not be parsed', 'hexloom-php-version-scan' ), ); } return is_array( $res ) ? $res : array( 'findings' => array(), 'skipped' => __( 'could not be parsed', 'hexloom-php-version-scan' ), ); } /** * Fold one file's result into the target row. * * @param array $row The target row. * @param string $rel File path relative to the target. * @param array $res findings and skipped. * @return void */ private static function add_result( array &$row, $rel, array $res ) { if ( ! empty( $res['skipped'] ) ) { self::add_skip( $row, $rel, (string) $res['skipped'] ); return; } ++$row['files_scanned']; $list = isset( $res['findings'] ) && is_array( $res['findings'] ) ? $res['findings'] : array(); foreach ( $list as $finding ) { if ( ! is_array( $finding ) ) { continue; } $level = ( isset( $finding['level'] ) && 'removed' === $finding['level'] ) ? 'removed' : 'deprecated'; $since = isset( $finding['since'] ) ? (string) $finding['since'] : ''; ++$row['counts'][ $level ]; if ( ! isset( $row['by_version'][ $since ] ) ) { $row['by_version'][ $since ] = array( 'removed' => 0, 'deprecated' => 0, ); } ++$row['by_version'][ $since ][ $level ]; if ( empty( $row['full'] ) && count( $row['findings'] ) < self::MAX_FINDINGS ) { $row['findings'][] = array( 'rule' => (string) ( isset( $finding['rule'] ) ? $finding['rule'] : '' ), 'line' => (int) ( isset( $finding['line'] ) ? $finding['line'] : 0 ), 'since' => $since, 'level' => $level, 'title' => (string) ( isset( $finding['title'] ) ? $finding['title'] : '' ), 'what' => (string) ( isset( $finding['what'] ) ? $finding['what'] : '' ), 'fix' => (string) ( isset( $finding['fix'] ) ? $finding['fix'] : '' ), 'doc' => esc_url_raw( (string) ( isset( $finding['doc'] ) ? $finding['doc'] : '' ), array( 'https' ) ), 'code' => (string) ( isset( $finding['code'] ) ? $finding['code'] : '' ), 'file' => $rel, ); } else { ++$row['truncated']; } } } /** * Record a skipped file (or a whole skipped folder when $rel is empty). * * @param array $row The target row. * @param string $rel Relative path. * @param string $reason Plain-English reason. * @return void */ private static function add_skip( array &$row, $rel, $reason ) { if ( count( $row['skipped'] ) < self::MAX_SKIPPED ) { $row['skipped'][] = array( 'path' => $rel, 'reason' => $reason, ); } else { ++$row['skipped_more']; } } /** * Move to the next target. * * @param array $state The state. * @param array $ctx The target just finished. * @return void */ private static function close( array &$state, array &$ctx ) { self::save_row( $state, $ctx ); ++$state['t']; ++$state['targets_done']; $state['last'] = ''; $state['inflight'] = array(); self::save( $state ); } /** * Promote the finished scan's rows into the report and mark the scan done. * * @param array $state The state. * @param array $q The queue. * @return void */ private static function finish( array &$state, array $q ) { $index = self::read( self::OPT_REPORT ); $old = ( null !== $index && isset( $index['rows'] ) && is_array( $index['rows'] ) ) ? $index['rows'] : array(); $rows = ! empty( $state['partial'] ) ? $old : array(); foreach ( $q as $i => $t ) { $rows[ $t['id'] ] = self::row_name( $state['id'], $i ); } self::write( self::OPT_REPORT, array( 'finished' => time(), 'rows' => $rows, ) ); foreach ( $old as $name ) { if ( ! in_array( $name, $rows, true ) ) { self::remove( (string) $name ); } } $state['status'] = 'done'; $state['finished'] = time(); $state['current'] = ''; } /** * Delete the result rows of a scan that is still running. * * @param array $state The state. * @return void */ private static function drop_work( array $state ) { for ( $i = 0; $i < (int) $state['targets_total']; $i++ ) { self::remove( self::row_name( (string) $state['id'], $i ) ); } } /** * The files of a target, relative paths in byte order. Links are not followed. * * @param string $root Target path (a folder, or one file). * @return string[] */ private static function list_files( $root ) { if ( is_file( $root ) ) { return array( basename( $root ) ); } $files = array(); $stack = array( '' ); while ( ! empty( $stack ) ) { $rel = array_pop( $stack ); $dir = '' === $rel ? $root : $root . '/' . $rel; if ( ! is_readable( $dir ) ) { continue; } $names = @scandir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a folder can vanish while a plugin is being updated; that is not an error. if ( ! is_array( $names ) ) { continue; } foreach ( $names as $name ) { if ( '.' === $name || '..' === $name ) { continue; } $child = '' === $rel ? $name : $rel . '/' . $name; $abs = $root . '/' . $child; if ( is_link( $abs ) ) { continue; } if ( is_dir( $abs ) ) { if ( ! in_array( $name, self::SKIP_DIRS, true ) ) { $stack[] = $child; } } elseif ( in_array( strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ), self::EXTENSIONS, true ) ) { $files[] = $child; if ( count( $files ) > self::MAX_FILES ) { break 2; } } } } sort( $files, SORT_STRING ); return $files; } /** * How many files a target has, 0 when it cannot be scanned. * * @param string $path Target path. * @return int */ private static function count_files( $path ) { if ( ! file_exists( $path ) || is_link( $path ) || ! self::allowed( $path ) ) { return 0; } return count( self::list_files( $path ) ); } /** * Entries of a sorted list that come after the cursor. * * @param string[] $files Sorted relative paths. * @param string $last Last path done ('' = none). * @return string[] */ private static function after( array $files, $last ) { if ( '' === $last ) { return $files; } foreach ( $files as $k => $f ) { if ( strcmp( $f, $last ) > 0 ) { return array_slice( $files, $k ); } } return array(); } /** * Only paths inside the plugin, mu-plugin and theme folders are ever read. * * @param string $path Absolute path. * @return bool */ private static function allowed( $path ) { global $wp_theme_directories; $real = realpath( $path ); if ( false === $real ) { return false; } $roots = array( WP_PLUGIN_DIR, WPMU_PLUGIN_DIR, get_theme_root() ); if ( is_array( $wp_theme_directories ) ) { $roots = array_merge( $roots, $wp_theme_directories ); } $cmp = ( '\\' === DIRECTORY_SEPARATOR ) ? 'strncasecmp' : 'strncmp'; $real = str_replace( '\\', '/', $real ) . '/'; foreach ( $roots as $root ) { $root = realpath( (string) $root ); if ( false === $root ) { continue; } $root = rtrim( str_replace( '\\', '/', $root ), '/' ) . '/'; if ( 0 === $cmp( $real, $root, strlen( $root ) ) ) { return true; } } return false; } /** * A fresh result row for a target. * * @param array $t The target. * @return array */ private static function new_row( array $t ) { return array( 'id' => (string) $t['id'], 'type' => (string) $t['type'], 'slug' => (string) $t['slug'], 'name' => (string) $t['name'], 'version' => (string) $t['version'], 'path' => (string) $t['path'], 'active' => ! empty( $t['active'] ), 'update_available' => (string) $t['update_available'], 'files_scanned' => 0, 'skipped' => array(), 'skipped_more' => 0, 'unscanned' => 0, 'findings' => array(), 'counts' => array( 'removed' => 0, 'deprecated' => 0, ), 'by_version' => array(), 'truncated' => 0, 'full' => false, ); } /** * Option name of one result row. * * @param string $scan Scan id. * @param int $index Position in the queue. * @return string */ private static function row_name( $scan, $index ) { return self::ROW_PREFIX . $scan . '_' . (int) $index; } /** * Store the open target's row; findings are trimmed so that the row stays under ROW_BYTES. * * @param array $state The state. * @param array $ctx The open target. * @return void */ private static function save_row( array $state, array &$ctx ) { $json = wp_json_encode( $ctx['row'] ); $big = is_string( $json ) && strlen( $json ) > self::ROW_BYTES; while ( $big && ! empty( $ctx['row']['findings'] ) ) { $keep = (int) ( count( $ctx['row']['findings'] ) * 0.8 ); $ctx['row']['truncated'] += count( $ctx['row']['findings'] ) - $keep; $ctx['row']['findings'] = array_slice( $ctx['row']['findings'], 0, $keep ); $ctx['row']['full'] = true; $json = wp_json_encode( $ctx['row'] ); $big = is_string( $json ) && strlen( $json ) > self::ROW_BYTES; } if ( is_string( $json ) ) { self::put( self::row_name( $state['id'], $state['t'] ), $json ); } } /** * The public view of the state. * * @param array $s The stored state. * @return array */ private static function view( array $s ) { return array( 'id' => (string) $s['id'], 'status' => (string) $s['status'], 'partial' => ! empty( $s['partial'] ), 'started' => (int) $s['started'], 'finished' => (int) $s['finished'], 'targets_total' => (int) $s['targets_total'], 'targets_done' => (int) $s['targets_done'], 'files_total' => (int) $s['files_total'], 'files_done' => (int) $s['files_done'], 'current' => (string) $s['current'], 'message' => (string) $s['message'], ); } /** * An error answer that is not stored. * * @param string $message Plain-English reason. * @return array */ private static function fail( $message ) { return array( 'id' => '', 'status' => 'error', 'partial' => false, 'started' => 0, 'finished' => 0, 'targets_total' => 0, 'targets_done' => 0, 'files_total' => 0, 'files_done' => 0, 'current' => '', 'message' => $message, ); } /** * Stop the stored scan with an error. * * @param array $state The state. * @param string $message Plain-English reason. * @return array */ private static function halt( array $state, $message ) { $state['status'] = 'error'; $state['message'] = $message; $state['lock'] = 0; self::save( $state ); return self::view( $state ); } /** * The version number inside a core update entry (an object for plugins, an array for themes). * * @param mixed $entry Update entry or null. * @return string */ private static function new_version( $entry ) { if ( is_object( $entry ) && isset( $entry->new_version ) ) { return (string) $entry->new_version; } if ( is_array( $entry ) && isset( $entry['new_version'] ) ) { return (string) $entry['new_version']; } return ''; } /** * Load the stored state. * * @return array|null */ private static function load() { $state = self::read( self::OPT_STATE ); if ( null === $state || ! isset( $state['id'], $state['status'], $state['t'], $state['last'] ) ) { return null; } $state += array( 'partial' => false, 'started' => 0, 'finished' => 0, 'targets_total' => 0, 'targets_done' => 0, 'files_total' => 0, 'files_done' => 0, 'current' => '', 'message' => '', 'lock' => 0, 'inflight' => array(), ); return $state; } /** * Load the stored state, bypassing the object cache, so a change made by another request is seen. * * @return array|null */ private static function fresh_state() { if ( is_multisite() ) { wp_cache_delete( get_current_network_id() . ':' . self::OPT_STATE, 'site-options' ); } else { wp_cache_delete( self::OPT_STATE, 'options' ); } return self::load(); } /** * Store the state. * * @param array $state The state. * @return void */ private static function save( array $state ) { self::write( self::OPT_STATE, $state ); } /** * Read a JSON option. Options live per network on multisite. * * @param string $name Option name. * @return array|null */ private static function read( $name ) { if ( 0 !== strpos( (string) $name, self::OPT_PREFIX ) ) { return null; // Names come from the stored index; never read an option that is not ours. } $raw = is_multisite() ? get_site_option( $name, '' ) : get_option( $name, '' ); if ( ! is_string( $raw ) || '' === $raw ) { return null; } $data = json_decode( $raw, true ); return is_array( $data ) ? $data : null; } /** * Write an array as a JSON option that is not autoloaded. * * @param string $name Option name. * @param array $data Value. * @return void */ private static function write( $name, array $data ) { $json = wp_json_encode( $data ); if ( is_string( $json ) ) { self::put( $name, $json ); } } /** * Write a string option that is not autoloaded. * * @param string $name Option name. * @param string $json Value. * @return void */ private static function put( $name, $json ) { if ( is_multisite() ) { update_site_option( $name, $json ); } else { update_option( $name, $json, false ); } } /** * Delete an option. * * @param string $name Option name. * @return void */ private static function remove( $name ) { if ( 0 !== strpos( (string) $name, self::OPT_PREFIX ) ) { return; // Names come from the stored index; never delete an option that is not ours (uninstall.php does the same). } if ( is_multisite() ) { delete_site_option( $name ); } else { delete_option( $name ); } } }