PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / app / Shared / Services / LaunchUpdate / LaunchUpdater.php

LaunchUpdater.php in Extendify 3.2.1, at app/Shared/Services/LaunchUpdate/LaunchUpdater.php

402 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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