| 1 |
<?php |
| 2 |
|
| 3 |
namespace Extendify\Shared\Services\LaunchUpdate; |
| 4 |
|
| 5 |
defined('ABSPATH') || die('No direct access.'); |
| 6 |
|
| 7 |
use Extendify\Config; |
| 8 |
use Extendify\Constants; |
| 9 |
use Extendify\Shared\Services\HttpClient; |
| 10 |
|
| 11 |
/** |
| 12 |
* Reports whether the Extendify plugin / Extendable theme are stale and applies |
| 13 |
* pending upgrades in the foreground at launch. Version-of-truth: /api/info. |
| 14 |
*/ |
| 15 |
class LaunchUpdater |
| 16 |
{ |
| 17 |
/** |
| 18 |
* The Extendable theme stylesheet. |
| 19 |
* |
| 20 |
* @var string |
| 21 |
*/ |
| 22 |
private static $themeStylesheet = 'extendable'; |
| 23 |
|
| 24 |
/** |
| 25 |
* /api/info's response, memoized per request. False once a fetch failed. |
| 26 |
* |
| 27 |
* @var array|boolean|null |
| 28 |
*/ |
| 29 |
private static $info = null; |
| 30 |
|
| 31 |
// phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound -- 7.0 floor: no const visibility |
| 32 |
const ATTEMPT_TRANSIENT = 'extendify_launch_update_attempts'; |
| 33 |
|
| 34 |
/** |
| 35 |
* A first try and one retry, then launch goes ahead on whatever is installed. |
| 36 |
* |
| 37 |
* @var integer |
| 38 |
*/ |
| 39 |
// phpcs:ignore PSR12.Properties.ConstantVisibility.NotFound -- 7.0 floor: no const visibility |
| 40 |
const MAX_ATTEMPTS = 2; |
| 41 |
|
| 42 |
/** |
| 43 |
* Apply pending foreground upgrades (theme + the PUC/partner plugin build) |
| 44 |
* and return any `errors`; never throws. The .org plugin build is reinstalled |
| 45 |
* by slug client-side via REST, so it isn't handled here. |
| 46 |
* |
| 47 |
* @return array |
| 48 |
*/ |
| 49 |
public static function run() |
| 50 |
{ |
| 51 |
try { |
| 52 |
self::loadDependencies(); |
| 53 |
|
| 54 |
$errors = []; |
| 55 |
|
| 56 |
if (self::pendingThemeVersion() !== null) { |
| 57 |
$error = self::upgradeTheme(); |
| 58 |
if ($error !== null) { |
| 59 |
$errors[] = 'theme: ' . $error; |
| 60 |
} |
| 61 |
} |
| 62 |
|
| 63 |
$pluginTarget = self::isWpOrgBuild() ? null : self::pendingPluginVersion(); |
| 64 |
if ($pluginTarget !== null) { |
| 65 |
$error = self::upgradePlugin($pluginTarget); |
| 66 |
if ($error !== null) { |
| 67 |
$errors[] = 'plugin: ' . $error; |
| 68 |
} |
| 69 |
} |
| 70 |
|
| 71 |
return ['errors' => $errors]; |
| 72 |
} catch (\Throwable $e) { |
| 73 |
return ['errors' => [$e->getMessage()]]; |
| 74 |
} |
| 75 |
} |
| 76 |
|
| 77 |
/** |
| 78 |
* Upgrade the theme in place (null on success, else an error). Feeds core's |
| 79 |
* Theme_Upgrader via a scoped transient read-filter, persisting nothing. |
| 80 |
* |
| 81 |
* @return string|null |
| 82 |
*/ |
| 83 |
private static function upgradeTheme() |
| 84 |
{ |
| 85 |
\add_filter('site_transient_update_themes', [self::class, 'injectThemeUpdate']); |
| 86 |
$skin = new \WP_Ajax_Upgrader_Skin(); |
| 87 |
$result = (new \Theme_Upgrader($skin))->upgrade(self::$themeStylesheet); |
| 88 |
\remove_filter('site_transient_update_themes', [self::class, 'injectThemeUpdate']); |
| 89 |
|
| 90 |
return self::upgradeError($result, $skin); |
| 91 |
} |
| 92 |
|
| 93 |
/** |
| 94 |
* Inject the Extendable entry into the (unpersisted) update_themes transient |
| 95 |
* so Theme_Upgrader has a package to install, even on a fresh install where |
| 96 |
* WP hasn't populated it. Only touches the extendable key. |
| 97 |
* |
| 98 |
* @param mixed $transient - The update_themes transient value being read. |
| 99 |
* @return mixed |
| 100 |
*/ |
| 101 |
public static function injectThemeUpdate($transient) |
| 102 |
{ |
| 103 |
$version = self::pendingThemeVersion(); |
| 104 |
if ($version === null) { |
| 105 |
return $transient; |
| 106 |
} |
| 107 |
|
| 108 |
if (!is_object($transient)) { |
| 109 |
$transient = new \stdClass(); |
| 110 |
} |
| 111 |
|
| 112 |
if (!isset($transient->response) || !is_array($transient->response)) { |
| 113 |
$transient->response = []; |
| 114 |
} |
| 115 |
|
| 116 |
$theme = \wp_get_theme(self::$themeStylesheet); |
| 117 |
$transient->response[self::$themeStylesheet] = [ |
| 118 |
'theme' => self::$themeStylesheet, |
| 119 |
'new_version' => $version, |
| 120 |
'url' => 'https://wordpress.org/themes/' . self::$themeStylesheet . '/', |
| 121 |
'package' => 'https://downloads.wordpress.org/theme/' . self::$themeStylesheet . '.' . $version . '.zip', |
| 122 |
'requires' => (string) $theme->get('RequiresWP'), |
| 123 |
'requires_php' => (string) $theme->get('RequiresPHP'), |
| 124 |
]; |
| 125 |
|
| 126 |
return $transient; |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* Install the latest PUC/partner plugin build straight from update-server's |
| 131 |
* package URL, overwriting the current one. `install()` (unlike `upgrade()`) |
| 132 |
* takes an explicit package, so it doesn't depend on the update transient. |
| 133 |
* |
| 134 |
* @param string $target - The version we expect to be running afterwards. |
| 135 |
* @return string|null |
| 136 |
*/ |
| 137 |
private static function upgradePlugin($target) |
| 138 |
{ |
| 139 |
$package = self::fetchPucPackage(); |
| 140 |
if ($package === null) { |
| 141 |
return 'no package'; |
| 142 |
} |
| 143 |
|
| 144 |
\add_filter('upgrader_source_selection', [self::class, 'useInstalledDirectory'], 10, 2); |
| 145 |
$skin = new \WP_Ajax_Upgrader_Skin(); |
| 146 |
$result = (new \Plugin_Upgrader($skin)) |
| 147 |
->install($package['download_url'], ['overwrite_package' => true]); |
| 148 |
\remove_filter('upgrader_source_selection', [self::class, 'useInstalledDirectory'], 10); |
| 149 |
|
| 150 |
$error = self::upgradeError($result, $skin); |
| 151 |
if ($error !== null) { |
| 152 |
return $error; |
| 153 |
} |
| 154 |
|
| 155 |
$installed = self::installedPluginVersion(); |
| 156 |
if ($installed !== '' && \version_compare($installed, $target, '<')) { |
| 157 |
return 'installed ' . $target . ' but the plugin is still on ' . $installed; |
| 158 |
} |
| 159 |
|
| 160 |
return null; |
| 161 |
} |
| 162 |
|
| 163 |
/** |
| 164 |
* Core derives the destination from the zip's root folder, which leaves a |
| 165 |
* second copy of the plugin beside the live one. |
| 166 |
* |
| 167 |
* @param string|\WP_Error $source - The extracted package folder. |
| 168 |
* @param string $remoteSource - The temporary folder holding it. |
| 169 |
* @return string|\WP_Error |
| 170 |
*/ |
| 171 |
public static function useInstalledDirectory($source, $remoteSource) |
| 172 |
{ |
| 173 |
global $wp_filesystem; |
| 174 |
|
| 175 |
if (\is_wp_error($source)) { |
| 176 |
return $source; |
| 177 |
} |
| 178 |
|
| 179 |
$installed = \trailingslashit($remoteSource) . \basename(EXTENDIFY_PATH) . '/'; |
| 180 |
if ($source === $installed || $source === \trailingslashit($remoteSource)) { |
| 181 |
return $source; |
| 182 |
} |
| 183 |
|
| 184 |
return $wp_filesystem->move($source, $installed, true) |
| 185 |
? $installed |
| 186 |
: new \WP_Error('extendify_rename_failed', 'Could not rename the package to ' . $installed); |
| 187 |
} |
| 188 |
|
| 189 |
/** |
| 190 |
* The upgrades for this launch page load to apply, spending one attempt. |
| 191 |
* `stale` holds the versions we gave up on reaching, so it's empty until we do. |
| 192 |
* |
| 193 |
* @return array |
| 194 |
*/ |
| 195 |
public static function claimPendingUpdates() |
| 196 |
{ |
| 197 |
$theme = self::pendingThemeVersion(); |
| 198 |
$plugin = self::pendingPluginVersion(); |
| 199 |
|
| 200 |
if ($theme === null && $plugin === null) { |
| 201 |
// The upgrade landed, or was never needed: the next launch gets both attempts. |
| 202 |
self::clearAttempts(); |
| 203 |
|
| 204 |
return ['theme' => false, 'plugin' => false, 'attempt' => 0, 'stale' => []]; |
| 205 |
} |
| 206 |
|
| 207 |
$attempt = self::claimAttempt(); |
| 208 |
if ($attempt === null) { |
| 209 |
return [ |
| 210 |
'theme' => false, |
| 211 |
'plugin' => false, |
| 212 |
'attempt' => self::MAX_ATTEMPTS, |
| 213 |
'stale' => \array_filter(['theme' => $theme, 'plugin' => $plugin]), |
| 214 |
]; |
| 215 |
} |
| 216 |
|
| 217 |
return [ |
| 218 |
'theme' => $theme !== null, |
| 219 |
'plugin' => $plugin !== null, |
| 220 |
'attempt' => $attempt, |
| 221 |
'stale' => [], |
| 222 |
]; |
| 223 |
} |
| 224 |
|
| 225 |
/** |
| 226 |
* The attempt to record, or null once they're spent. An upgrade always ends in |
| 227 |
* a reload, so serving the launch update and trying it are the same event. |
| 228 |
* |
| 229 |
* @return integer|null |
| 230 |
*/ |
| 231 |
private static function claimAttempt() |
| 232 |
{ |
| 233 |
$attempt = (int) \get_transient(self::ATTEMPT_TRANSIENT) + 1; |
| 234 |
if ($attempt > self::MAX_ATTEMPTS) { |
| 235 |
return null; |
| 236 |
} |
| 237 |
|
| 238 |
\set_transient(self::ATTEMPT_TRANSIENT, $attempt, HOUR_IN_SECONDS); |
| 239 |
|
| 240 |
return $attempt; |
| 241 |
} |
| 242 |
|
| 243 |
/** |
| 244 |
* Hand the next launch a fresh pair; the expiry covers one abandoned mid-way. |
| 245 |
* |
| 246 |
* @return void |
| 247 |
*/ |
| 248 |
public static function clearAttempts() |
| 249 |
{ |
| 250 |
\delete_transient(self::ATTEMPT_TRANSIENT); |
| 251 |
} |
| 252 |
|
| 253 |
/** |
| 254 |
* The newer plugin version to install per /api/info, or null if current. |
| 255 |
* |
| 256 |
* @return string|null |
| 257 |
*/ |
| 258 |
private static function pendingPluginVersion() |
| 259 |
{ |
| 260 |
$info = self::fetchInfo(); |
| 261 |
if (empty($info['extendify']['version'])) { |
| 262 |
return null; |
| 263 |
} |
| 264 |
|
| 265 |
return \version_compare($info['extendify']['version'], Config::$version, '>') |
| 266 |
? $info['extendify']['version'] |
| 267 |
: null; |
| 268 |
} |
| 269 |
|
| 270 |
/** |
| 271 |
* The newer Extendable version to install per /api/info, or null if current. |
| 272 |
* |
| 273 |
* @return string|null |
| 274 |
*/ |
| 275 |
private static function pendingThemeVersion() |
| 276 |
{ |
| 277 |
$theme = \wp_get_theme(self::$themeStylesheet); |
| 278 |
if (!$theme->exists()) { |
| 279 |
return null; |
| 280 |
} |
| 281 |
|
| 282 |
$info = self::fetchInfo(); |
| 283 |
if (empty($info['extendable']['version'])) { |
| 284 |
return null; |
| 285 |
} |
| 286 |
|
| 287 |
return \version_compare($info['extendable']['version'], $theme->get('Version'), '>') |
| 288 |
? $info['extendable']['version'] |
| 289 |
: null; |
| 290 |
} |
| 291 |
|
| 292 |
/** |
| 293 |
* Whether this is the wordpress.org build. Its `updater.php` is stripped by |
| 294 |
* release-to-wp-org.yml (partner/PUC builds keep it); the wp.org build is |
| 295 |
* reinstalled by slug through the core REST route, client-side. |
| 296 |
* |
| 297 |
* @return boolean |
| 298 |
*/ |
| 299 |
public static function isWpOrgBuild() |
| 300 |
{ |
| 301 |
return !\is_readable(EXTENDIFY_PATH . 'updater.php'); |
| 302 |
} |
| 303 |
|
| 304 |
/** |
| 305 |
* The version on disk, read from the same file as Config::$version, which was |
| 306 |
* read before the upgrade. Empty when it can't be read. |
| 307 |
* |
| 308 |
* @return string |
| 309 |
*/ |
| 310 |
private static function installedPluginVersion() |
| 311 |
{ |
| 312 |
$data = \get_file_data(EXTENDIFY_PATH . 'readme.txt', ['version' => 'Stable tag']); |
| 313 |
|
| 314 |
return $data['version']; |
| 315 |
} |
| 316 |
|
| 317 |
/** |
| 318 |
* Plugin + theme versions from /api/info, or null on failure. Memoized per |
| 319 |
* request — both detection checks (and the theme filter) call it. |
| 320 |
* |
| 321 |
* @return array|null |
| 322 |
*/ |
| 323 |
private static function fetchInfo() |
| 324 |
{ |
| 325 |
if (self::$info === null) { |
| 326 |
$result = HttpClient::get(Constants::AI_HOST . '/api/info', [ |
| 327 |
'params' => [ |
| 328 |
'partnerId' => defined('EXTENDIFY_PARTNER_ID') ? constant('EXTENDIFY_PARTNER_ID') : null, |
| 329 |
'siteId' => \get_option('extendify_site_id', null), |
| 330 |
'pluginVersion' => Config::$version, |
| 331 |
'themeVersion' => (string) \wp_get_theme(self::$themeStylesheet)->get('Version'), |
| 332 |
], |
| 333 |
]); |
| 334 |
self::$info = $result['code'] === 200 ? $result['response'] : false; |
| 335 |
} |
| 336 |
|
| 337 |
return self::$info ?: null; |
| 338 |
} |
| 339 |
|
| 340 |
/** |
| 341 |
* The PUC/partner build's package (`download_url`) from update-server, used to |
| 342 |
* apply the plugin update; detection uses /api/info. Null on failure. |
| 343 |
* |
| 344 |
* @return array|null |
| 345 |
*/ |
| 346 |
private static function fetchPucPackage() |
| 347 |
{ |
| 348 |
$url = 'https://update-server.extendify.com/plugin/update?' . \http_build_query([ |
| 349 |
'partnerId' => defined('EXTENDIFY_PARTNER_ID') ? constant('EXTENDIFY_PARTNER_ID') : null, |
| 350 |
'siteId' => \get_option('extendify_site_id', null), |
| 351 |
'homeUrl' => \get_home_url(), |
| 352 |
'wordpressVersion' => \get_bloginfo('version'), |
| 353 |
'checking_for_updates' => 1, |
| 354 |
]); |
| 355 |
|
| 356 |
$response = \wp_remote_get($url); |
| 357 |
if (\is_wp_error($response) || \wp_remote_retrieve_response_code($response) !== 200) { |
| 358 |
return null; |
| 359 |
} |
| 360 |
|
| 361 |
$data = \json_decode(\wp_remote_retrieve_body($response), true); |
| 362 |
return (is_array($data) && !empty($data['download_url'])) ? $data : null; |
| 363 |
} |
| 364 |
|
| 365 |
/** |
| 366 |
* The error message from an upgrader run (skin errors first, then the |
| 367 |
* return value), or null when it succeeded. |
| 368 |
* |
| 369 |
* @param mixed $result - The upgrader ::upgrade()/::install() return. |
| 370 |
* @param \WP_Ajax_Upgrader_Skin $skin - The skin that ran the upgrade. |
| 371 |
* @return string|null |
| 372 |
*/ |
| 373 |
private static function upgradeError($result, $skin) |
| 374 |
{ |
| 375 |
$skinErrors = $skin->get_errors(); |
| 376 |
if (\is_wp_error($skinErrors) && $skinErrors->has_errors()) { |
| 377 |
return $skinErrors->get_error_message(); |
| 378 |
} |
| 379 |
|
| 380 |
if (\is_wp_error($result)) { |
| 381 |
return $result->get_error_message(); |
| 382 |
} |
| 383 |
|
| 384 |
return null; |
| 385 |
} |
| 386 |
|
| 387 |
/** |
| 388 |
* Load the WordPress upgrader API, which is only present in the admin context. |
| 389 |
* |
| 390 |
* @return void |
| 391 |
*/ |
| 392 |
private static function loadDependencies() |
| 393 |
{ |
| 394 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 395 |
require_once ABSPATH . 'wp-admin/includes/misc.php'; |
| 396 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 397 |
require_once ABSPATH . 'wp-admin/includes/theme.php'; |
| 398 |
require_once ABSPATH . 'wp-admin/includes/update.php'; |
| 399 |
require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; |
| 400 |
} |
| 401 |
} |
| 402 |
|