,checks:array,error?:string} */ public static function preflight(string $kind, string $slug): array { $item = self::item($kind, $slug); if ($item === null) { return ['checks' => [], 'error' => __('That has no update waiting.', 'sarcio'), 'item' => null, 'ok' => false]; } $checks = self::checks($item, true); $ok = true; foreach ($checks as $check) { $ok = $ok && $check['level'] !== 'stop'; } return ['checks' => $checks, 'item' => $item, 'ok' => $ok]; } /** * The checks for one pending update; `$lookUp` adds the one that asks * WordPress.org when it was last updated. * * @param array $item * @return array */ public static function checks(array $item, bool $lookUp = false): array { $checks = []; $stop = static function (string $text) use (&$checks): void { $checks[] = ['level' => 'stop', 'text' => $text]; }; $warn = static function (string $text) use (&$checks): void { $checks[] = ['level' => 'warn', 'text' => $text]; }; if (!$item['fromDirectory']) { $stop(__('It does not come from WordPress.org, so Sarcio could not put the earlier version back if something broke. Update it from the Plugins or Themes screen instead.', 'sarcio')); } if ($item['requiresPhp'] !== '' && version_compare(PHP_VERSION, $item['requiresPhp'], '<')) { /* translators: 1: the PHP version the update needs, 2: the PHP version the server runs. */ $stop(sprintf(__('The new version needs PHP %1$s, and your server runs %2$s. Ask your host to upgrade PHP first.', 'sarcio'), $item['requiresPhp'], PHP_VERSION)); } $wordpress = (string) get_bloginfo('version'); if ($item['requires'] !== '' && $wordpress !== '' && version_compare($wordpress, $item['requires'], '<')) { /* translators: 1: the WordPress version the update needs, 2: the site's WordPress version. */ $stop(sprintf(__('The new version needs WordPress %1$s, and this site runs %2$s. Update WordPress first.', 'sarcio'), $item['requires'], $wordpress)); } if (function_exists('wp_is_file_mod_allowed') && !wp_is_file_mod_allowed('sarcio_safe_update')) { $stop(__('This site is set up so that plugins and themes cannot be changed from WordPress.', 'sarcio')); } $free = function_exists('disk_free_space') && defined('WP_CONTENT_DIR') && is_dir(WP_CONTENT_DIR) ? disk_free_space(WP_CONTENT_DIR) : false; if (is_float($free) && $free < self::MIN_FREE_BYTES) { $stop(__('The server is almost out of disk space, so there is no room to keep the earlier version.', 'sarcio')); } if ($item['tested'] !== '' && $wordpress !== '' && version_compare(self::majorMinor($wordpress), self::majorMinor($item['tested']), '>')) { /* translators: 1: the WordPress version the plugin was last tested with, 2: the site's WordPress version. */ $warn(sprintf(__('Its makers last tested it with WordPress %1$s; this site runs %2$s.', 'sarcio'), $item['tested'], $wordpress)); } if (self::major($item['current']) !== self::major($item['new'])) { $warn(in_array($item['slug'], self::DATABASE_PLUGINS, true) ? __('This is a big update that may also change its data in the database. Putting the earlier version back restores its files, not that data, so it is worth a backup first.', 'sarcio') : __('This is a big update (a new major version).', 'sarcio')); } if (function_exists('wp_check_php_version')) { $php = wp_check_php_version(); if (is_array($php) && isset($php['is_secure']) && !$php['is_secure']) { $warn(__('Your server\'s PHP no longer gets security fixes. Ask your host to upgrade it.', 'sarcio')); } } $stale = $lookUp ? self::lastUpdated($item) : null; if ($stale !== null) { $warn($stale); } return $checks; } /** @return array */ public static function run(string $kind, string $slug): array { $pre = self::preflight($kind, $slug); if ($pre['item'] === null) { return ['error' => $pre['error'] ?? __('That has no update waiting.', 'sarcio')]; } if (!$pre['ok']) { foreach ($pre['checks'] as $check) { if ($check['level'] === 'stop') { return ['error' => $check['text']]; } } } $item = $pre['item']; UpdateAbilities::loadAdmin(); $pages = SiteChecks::keyPages(); $before = SiteChecks::look($pages); if (!SiteChecks::reachable($before)) { return ['error' => __('Sarcio could not open this site\'s pages from its own server (some hosts block that), so it could not check the update and did not run it. You can still update from the Plugins or Themes screen.', 'sarcio')]; } $backup = UpdateAbilities::backupFirst(); if (!self::upgrade($kind, $item)) { return ['error' => __('WordPress could not update it. Nothing changed.', 'sarcio')]; } Cache::purgeAfterChange(); $after = SiteChecks::look($pages); $diff = SiteChecks::compare($before, $after); $journalBefore = $item + ['kind' => $kind, 'version' => $item['current']]; $changeId = Journal::record( self::ABILITY, sprintf( /* translators: 1: the plugin or theme name, 2: the old version, 3: the new version. */ __('Updated %1$s from %2$s to %3$s, with checks', 'sarcio'), $item['name'], $item['current'], $item['new'], ), $kind . ':' . $item['slug'], $journalBefore, ['pages' => count($pages), 'version' => $item['new']], ); $outcome = 'ok'; $findings = []; if ($diff['hard'] !== []) { $findings = array_merge($diff['hard'], $diff['soft']); $restored = $kind === 'theme' ? UpdateAbilities::rollbackTheme($journalBefore) : UpdateAbilities::rollback($journalBefore); if (empty($restored['error'])) { Journal::markUndone($changeId); Cache::purgeAfterChange(); $outcome = 'rolled_back'; } else { $outcome = 'rollback_failed'; } } elseif ($diff['soft'] !== []) { $findings = $diff['soft']; $outcome = 'held'; } $reportId = $outcome === 'ok' ? null : self::tellSarcio($kind, $item, $outcome, $findings, $changeId); return [ 'backup' => $backup, 'changeId' => $changeId, 'checked' => count($pages), 'findings' => $findings, 'outcome' => $outcome, 'reportId' => $reportId, 'summary' => self::summary($item, $outcome, count($pages)), ]; } /** Tell Sarcio what the owner decided about an update report; best-effort. */ public static function decide(string $reportId, string $decision): void { [$base, $siteKey, $secret] = self::credentials(); if ($siteKey === '' || $secret === '' || !preg_match('~^[0-9a-f-]{36}$~', $reportId)) { return; } wp_remote_post( sprintf('%s/api/sites/%s/update-checks/%s/decision', $base, rawurlencode($siteKey), rawurlencode($reportId)), [ 'body' => (string) wp_json_encode(['decision' => $decision]), 'headers' => ['Authorization' => 'Bearer ' . $secret, 'Content-Type' => 'application/json'], 'redirection' => 0, 'timeout' => 8, ], ); } /** * @param array $item * @param array $findings */ private static function tellSarcio(string $kind, array $item, string $outcome, array $findings, int $changeId): ?string { [$base, $siteKey, $secret] = self::credentials(); if ($siteKey === '' || $secret === '' || $findings === []) { return null; } $clip = static fn (string $text, int $max): string => mb_substr($text, 0, $max); $response = wp_remote_post( sprintf('%s/api/sites/%s/update-checks', $base, rawurlencode($siteKey)), [ 'body' => (string) wp_json_encode([ 'findings' => array_map(static fn (array $finding): array => [ 'after' => $clip($finding['after'], 200), 'before' => $clip($finding['before'], 200), 'page' => $clip($finding['page'], 120), 'url' => $finding['url'], ], array_slice($findings, 0, 20)), 'item' => [ 'from' => $clip((string) $item['current'], 40), 'kind' => $kind, 'name' => $clip((string) $item['name'], 200), 'slug' => (string) $item['slug'], 'to' => $clip((string) $item['new'], 40), ], 'journalId' => $changeId, 'outcome' => $outcome, ]), 'headers' => ['Authorization' => 'Bearer ' . $secret, 'Content-Type' => 'application/json'], 'redirection' => 0, 'timeout' => 8, ], ); if (is_wp_error($response) || (int) wp_remote_retrieve_response_code($response) !== 201) { return null; } $body = json_decode((string) wp_remote_retrieve_body($response), true); $id = is_array($body) ? ($body['reportId'] ?? null) : null; return is_string($id) && preg_match('~^[0-9a-f-]{36}$~', $id) ? $id : null; } /** @return array{0:string,1:string,2:string} */ private static function credentials(): array { return [ Options::apiUrl(), (string) Options::get('site_key'), (string) Options::get('sidecar_key'), ]; } /** @param array $item */ private static function upgrade(string $kind, array $item): bool { if ($kind === 'theme') { $upgrader = new \Theme_Upgrader(new \WP_Ajax_Upgrader_Skin()); $result = $upgrader->upgrade((string) $item['slug']); if (function_exists('wp_clean_themes_cache')) { wp_clean_themes_cache(true); } } else { $result = UpdateAbilities::upgradePlugin((string) $item['file']); } return !is_wp_error($result) && (bool) $result; } /** @return array|null */ private static function item(string $kind, string $slug): ?array { if ($kind === 'theme') { $theme = UpdateAbilities::pendingThemes()[$slug] ?? null; return $theme === null ? null : $theme + ['file' => '']; } return UpdateAbilities::find($slug); } /** @param array $item */ private static function lastUpdated(array $item): ?string { if (empty($item['fromDirectory']) || !function_exists('plugins_api') || ($item['file'] ?? '') === '') { return null; } $info = plugins_api('plugin_information', ['fields' => ['last_updated' => true, 'sections' => false], 'slug' => (string) $item['slug']]); $when = is_object($info) && is_string($info->last_updated ?? null) ? strtotime($info->last_updated) : false; if ($when === false || time() - $when < self::STALE_SECONDS) { return null; } return __('Its makers have not updated it in over two years. Consider a replacement that is still looked after.', 'sarcio'); } /** @param array $item */ private static function summary(array $item, string $outcome, int $pages): string { $name = (string) $item['name']; return match ($outcome) { /* translators: 1: the plugin or theme name, 2: the new version, 3: how many pages were checked. */ 'ok' => sprintf(__('Updated %1$s to %2$s and checked %3$d key pages: all fine.', 'sarcio'), $name, $item['new'], $pages), /* translators: 1: the plugin or theme name, 2: the version that was put back. */ 'rolled_back' => sprintf(__('Updating %1$s broke part of the site, so Sarcio put %2$s back. Everything works as before. The details are in the Inbox.', 'sarcio'), $name, $item['current']), /* translators: 1: the plugin or theme name, 2: the version that could not be put back. */ 'rollback_failed' => sprintf(__('Updating %1$s broke part of the site, and putting %2$s back did not work. Look at the Inbox now.', 'sarcio'), $name, $item['current']), /* translators: %s: the plugin or theme name. */ default => sprintf(__('Updated %s, but some pages look different now. It is in the Inbox for you to keep or undo.', 'sarcio'), $name), }; } private static function major(string $version): string { return (string) strtok($version, '.'); } private static function majorMinor(string $version): string { $parts = explode('.', $version); return ($parts[0] ?? '0') . '.' . ($parts[1] ?? '0'); } }