PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
← All changes | includes/Utils/Installer.php +399 -31 3.7.13.8.0 View file →
@@ -2,8 +2,11 @@
2 2
3 3 namespace Templately\Utils;
4 4
5 5 use Automatic_Upgrader_Skin;
6 +use Templately\Modules\ProPluginProvisioning\Archive as ProProvisioningArchive;
7 +use Templately\Modules\ProPluginProvisioning\Catalog as ProProvisioningCatalog;
8 +use Templately\Modules\ProPluginProvisioning\Module as ProProvisioningModule;
6 9 use Plugin_Upgrader;
7 10 use Theme_Upgrader;
8 11 use WP_Ajax_Upgrader_Skin;
9 12 use WP_Filesystem_Base;
@@ -46,13 +49,13 @@
46 49 $response['slug'] = $plugin['slug'];
47 50 return $response;
48 51 }
49 52
50 - if ( isset( $plugin['is_pro'] ) && $plugin['is_pro'] ) {
51 - if ( ! $is_installed ) {
52 - $response['code'] = 'pro_plugin';
53 - $response['message'] = 'Pro Plugin';
54 - }
53 + $is_pro = ! empty( $plugin['is_pro'] );
54 +
55 + if ( $is_pro && ! $is_installed ) {
56 + $response['code'] = 'pro_plugin';
57 + $response['message'] = 'Pro Plugin';
55 58 }
56 59
57 60 if ( ! $is_installed ) {
58 61 if(!Helper::current_user_can( 'install_plugins' )){
@@ -60,40 +63,79 @@
60 63 $response['message'] = __( 'Sorry, you do not have permission to install a plugin.', 'templately' );
61 64 return $response;
62 65 }
63 66
64 - $plugins_api_args = [
65 - 'slug' => sanitize_key( wp_unslash( $plugin['slug'] ) ),
66 - 'fields' => [
67 - 'sections' => false,
68 - ],
69 - ];
70 - if(isset($plugin['has_license']) && $plugin['has_license']){
71 - $plugins_api_args['has_license'] = $plugin['has_license'];
72 - }
73 67 /**
74 - * @var array|object $api
68 + * A pro plugin has no wordpress.org record, so `plugins_api()` cannot supply a
69 + * download link for one. Before spec 059 that was the end of it: the response
70 + * above stood, and the user was told to install the plugin by hand.
71 + *
72 + * Now we ASK first. If a registered provisioning source can supply an archive
73 + * — because the user's subscription entitles them to it — we install from that
74 + * path and skip `plugins_api()` entirely. If nothing answers, the `pro_plugin`
75 + * response above is returned untouched, which is byte-for-byte the behaviour
76 + * every non-entitled user has always had.
77 + *
78 + * The ask is made only for plugins that are actually obtainable, and the
79 + * catalog — never the descriptor's own claim — decides which those are: this
80 + * governs what gets downloaded from a third party and executed on the site, so
81 + * a spoofed dependency response must not be able to widen it.
82 + *
83 + * @see modules/pro-plugin-provisioning/
84 + * @see specs/059-pro-plugin-provisioning/contracts/provisioning-source.md
75 85 */
76 - $api = plugins_api( 'plugin_information', $plugins_api_args);
86 + $provisioned = $is_pro ? $this->provision_pro_archive( $plugin ) : null;
77 87
88 + if ( null === $provisioned && $is_pro ) {
89 + // Not obtainable. Return the refusal rather than asking wordpress.org for
90 + // a plugin it has never heard of.
91 + return $response;
92 + }
78 93
79 - if ( is_wp_error( $api ) ) {
80 - $response['message'] = $api->get_error_message();
94 + if ( null === $provisioned ) {
95 + $plugins_api_args = [
96 + 'slug' => sanitize_key( wp_unslash( $plugin['slug'] ) ),
97 + 'fields' => [
98 + 'sections' => false,
99 + ],
100 + ];
101 + if(isset($plugin['has_license']) && $plugin['has_license']){
102 + $plugins_api_args['has_license'] = $plugin['has_license'];
103 + }
104 + /**
105 + * @var array|object $api
106 + */
107 + $api = plugins_api( 'plugin_information', $plugins_api_args);
81 108
82 - return $response;
83 - }
84 109
85 - $compatibility = $this->check_compatibility($api);
86 - if (!$compatibility['success']) {
87 - return $compatibility;
110 + if ( is_wp_error( $api ) ) {
111 + $response['message'] = $api->get_error_message();
112 +
113 + return $response;
114 + }
115 +
116 + $compatibility = $this->check_compatibility($api);
117 + if (!$compatibility['success']) {
118 + return $compatibility;
119 + }
120 +
121 + $response['name'] = $api->name;
122 + } else {
123 + // A provisioned pro plugin: the refusal recorded above no longer applies.
124 + // UNSET, not '' — an empty-string code survives every `?? 'fallback'` in the
125 + // callers, and `new WP_Error( '', … )` early-returns with no error at all,
126 + // which the REST server turns into a 500 with a null body.
127 + unset( $response['code'], $response['message'] );
128 + $response['name'] = $plugin['name'] ?? $plugin['slug'];
88 129 }
89 130
90 - $response['name'] = $api->name;
91 -
92 131 $skin = new WP_Ajax_Upgrader_Skin();
93 132 $upgrader = new Plugin_Upgrader( $skin );
94 - $result = $upgrader->install( $api->download_link );
133 + $result = $upgrader->install( null !== $provisioned ? $provisioned : $api->download_link );
95 134
135 + // Nothing licensed survives the request that downloaded it, on either branch.
136 + ProProvisioningArchive::discard( $provisioned );
137 +
96 138 if ( is_wp_error( $result ) ) {
97 139 $response['code'] = $result->get_error_code();
98 140 $response['message'] = $result->get_error_message();
99 141
@@ -122,18 +164,31 @@
122 164 $response['message'] = __('Failed to install plugin', 'templately');
123 165 return $response;
124 166 }
125 167
126 - $install_status = install_plugin_install_status( $api );
127 - $plugin['plugin_file'] = $install_status['file'];
168 + if ( null === $provisioned ) {
169 + $install_status = install_plugin_install_status( $api );
170 + $plugin['plugin_file'] = $install_status['file'];
171 + }
172 + // A provisioned plugin keeps the plugin_file the dependency descriptor named:
173 + // `install_plugin_install_status()` resolves it from a wordpress.org API
174 + // object, and a pro plugin has none.
128 175 }
129 176
130 - if ( !Helper::current_user_can( 'activate_plugins' ) && is_plugin_inactive( $file ) ) {
177 + if ( !Helper::current_user_can( 'activate_plugins' ) && is_plugin_inactive( $plugin['plugin_file'] ) ) {
131 178 $response['code'] = 'invalid_requirements';
132 179 $response['message'] = __( 'Sorry, you do not have permission to activate a plugin.', 'templately' );
133 180 return $response;
134 181 }
135 182
183 + // Claim the install immediately before activating, so the caching plugin comes
184 + // up as a host install — every feature off, page caching on only if nothing
185 + // else owns it, and its own setup wizard skipped. One option write; it does
186 + // the rest on its own activation, so there is nothing here to finish or undo.
187 + if ( Caching::PLUGIN_FILE === $plugin['plugin_file'] ) {
188 + Caching::claim_install();
189 + }
190 +
136 191 $activate_status = $this->activate_plugin( $plugin['plugin_file'] );
137 192
138 193 if ( is_wp_error( $activate_status ) ) {
139 194 $response['message'] = $activate_status->get_error_message();
@@ -147,8 +202,191 @@
147 202
148 203 return $response;
149 204 }
150 205
206 + /**
207 + * Update already-installed plugins to their latest available version.
208 + *
209 + * Exists because a pack's markup is produced by the block plugin version on the AUTHORING
210 + * site: when the importing site runs an older one, blocks whose `save()` has since changed
211 + * — or whose newer attributes this version does not declare — fail Gutenberg's validation
212 + * and open with "Attempt recovery". Updating first is the only fix that keeps the pack's
213 + * newer features; regenerating the markup locally would silently drop them.
214 + *
215 + * Mirrors {@see self::install()}'s result contract so callers can treat the two alike.
216 + *
217 + * NOTE: the updated code is NOT loaded in the request that updates it. Callers must
218 + * re-check state in a FRESH request before acting on it — the same constraint the FSI
219 + * dependency runner handles with its `plugins_installed` → `continue` → new-request split.
220 + *
221 + * @param string[] $plugin_files Plugin basenames, e.g. `essential-blocks/essential-blocks.php`.
222 + * @return array{success:bool,results:array<int,array<string,mixed>>}
223 + */
224 + public function update( array $plugin_files ): array {
225 + require_once ABSPATH . 'wp-admin/includes/plugin.php';
226 + require_once ABSPATH . 'wp-admin/includes/file.php';
227 + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
228 + require_once ABSPATH . 'wp-admin/includes/update.php';
229 +
230 + $results = [];
231 +
232 + if ( ! Helper::current_user_can( 'update_plugins' ) ) {
233 + return [
234 + 'success' => false,
235 + 'code' => 'invalid_permission',
236 + 'message' => __( 'Sorry, you do not have permission to update plugins.', 'templately' ),
237 + 'results' => [],
238 + ];
239 + }
240 +
241 + self::raise_limits();
242 +
243 + $installed = Helper::get_plugins();
244 +
245 + foreach ( $plugin_files as $plugin_file ) {
246 + $plugin_file = (string) $plugin_file;
247 + $from = $installed[ $plugin_file ]['Version'] ?? null;
248 +
249 + if ( ! isset( $installed[ $plugin_file ] ) ) {
250 + $results[] = [
251 + 'plugin_file' => $plugin_file,
252 + 'updated' => false,
253 + 'code' => 'not_installed',
254 + 'message' => __( 'This plugin is not installed on the site.', 'templately' ),
255 + ];
256 + continue;
257 + }
258 +
259 + $was_active = is_plugin_active( $plugin_file );
260 +
261 + // Core's own precondition, checked BEFORE handing the plugin over:
262 + // `Plugin_Upgrader::upgrade()` bails with `up_to_date` when the plugin has no
263 + // entry in the `update_plugins` transient. Asking first lets us report that as
264 + // settled; letting the upgrader discover it yields an error we cannot identify,
265 + // because `WP_Ajax_Upgrader_Skin::error()` files a string code under a GENERATED
266 + // key (`unknown_upgrade_error_N`) — the literal 'up_to_date' never survives, so
267 + // there is nothing downstream to match on.
268 + $current = get_site_transient( 'update_plugins' );
269 + if ( ! isset( $current->response[ $plugin_file ] ) ) {
270 + $results[] = [
271 + 'plugin_file' => $plugin_file,
272 + 'updated' => true,
273 + 'code' => 'already_updated',
274 + 'from' => $from,
275 + 'to' => $from,
276 + ];
277 + continue;
278 + }
279 +
280 + $skin = new WP_Ajax_Upgrader_Skin();
281 + $upgrader = new Plugin_Upgrader( $skin );
282 + // `clear_update_cache => false` is REQUIRED in a loop, and core's own
283 + // `bulk_upgrade()` does exactly this. Left at its default of true, `upgrade()`
284 + // hooks `wp_clean_plugins_cache` onto `upgrader_process_complete`, so the FIRST
285 + // plugin to upgrade successfully deletes the `update_plugins` transient — and
286 + // every later plugin in this loop then re-reads that now-empty transient at the
287 + // top of `upgrade()`, finds no `response[$plugin]`, and fails with `up_to_date`
288 + // ("The plugin is at the latest version.") without being touched. Updating three
289 + // plugins updated one and falsely reported the other two as already current.
290 + // The single `wp_clean_plugins_cache( true )` after the loop does the refresh
291 + // once, which is the whole point of deferring it.
292 + $result = $upgrader->upgrade( $plugin_file, [ 'clear_update_cache' => false ] );
293 +
294 + $error = null;
295 + if ( is_wp_error( $result ) ) {
296 + $error = $result;
297 + } elseif ( is_wp_error( $skin->result ) ) {
298 + $error = $skin->result;
299 + } elseif ( $skin->get_errors()->has_errors() ) {
300 + $error = $skin->get_errors();
301 + }
302 +
303 + if ( null !== $error ) {
304 + $results[] = [
305 + 'plugin_file' => $plugin_file,
306 + 'updated' => false,
307 + 'code' => $error->get_error_code(),
308 + 'message' => $error->get_error_message(),
309 + 'from' => $from,
310 + ];
311 + continue;
312 + }
313 +
314 + if ( false === $result ) {
315 + $results[] = [
316 + 'plugin_file' => $plugin_file,
317 + 'updated' => false,
318 + 'code' => 'update_failed',
319 + 'message' => __( 'The update could not be completed. Please update this plugin from the Plugins screen.', 'templately' ),
320 + 'from' => $from,
321 + ];
322 + continue;
323 + }
324 +
325 + // WP deactivates a plugin while upgrading it; put it back the way we found it.
326 + //
327 + // SILENTLY, and that is not a detail. `Plugin_Upgrader` deactivates with
328 + // `deactivate_plugins( $plugin, true )` — hooks suppressed — because the plugin
329 + // is not being turned off, it is being swapped underneath. Re-activating it
330 + // loudly runs its ACTIVATION hooks, and those belong to the version now on disk
331 + // while this request is still running the autoloader it booted with. WooCommerce
332 + // 11.1.1's `WC_Install::install()` reaches for a class that exists only in its
333 + // own build and fatals the whole request: every plugin after it in this loop
334 + // goes un-updated, and the user is told a critical error occurred for all of
335 + // them — for plugins that in fact updated correctly.
336 + //
337 + // A wrapper restoring prior state has no business running first-run setup. The
338 + // plugin's own upgrade routine runs on the NEXT request, from its own code,
339 + // which is where it can work.
340 + if ( $was_active && ! is_plugin_active( $plugin_file ) ) {
341 + try {
342 + $this->activate_plugin( $plugin_file, true );
343 + } catch ( \Throwable $e ) {
344 + // The UPDATE succeeded; only putting it back failed. Say so, and let the
345 + // rest of the run continue — aborting here would strand every plugin
346 + // after this one, which is exactly what the fatal above did.
347 + Helper::log(
348 + sprintf( 'update: %s updated but could not be re-activated — %s', $plugin_file, $e->getMessage() ),
349 + 'installer_update',
350 + 'warning'
351 + );
352 + }
353 + }
354 +
355 + // Read the version off disk rather than the stale in-memory copy.
356 + $to = null;
357 + if ( function_exists( 'get_plugin_data' ) && file_exists( WP_PLUGIN_DIR . '/' . $plugin_file ) ) {
358 + $data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, false, false );
359 + $to = $data['Version'] ?? null;
360 + }
361 +
362 + $results[] = [
363 + 'plugin_file' => $plugin_file,
364 + 'updated' => true,
365 + 'from' => $from,
366 + 'to' => $to,
367 + ];
368 + }
369 +
370 + // A fresh check so the next read of the update transient reflects what we just did.
371 + // The argument is `$clear_update_cache`, and it MUST be true: `false` deletes only the
372 + // `plugins` object cache and LEAVES the `update_plugins` transient in place, so every
373 + // plugin we just updated keeps its stale "update available" entry and comes back as a
374 + // recommendation on the next dependency check.
375 + if ( function_exists( 'wp_clean_plugins_cache' ) ) {
376 + wp_clean_plugins_cache( true );
377 + }
378 +
379 + $failed = array_filter( $results, function ( $result ) {
380 + return empty( $result['updated'] );
381 + } );
382 +
383 + return [
384 + 'success' => empty( $failed ),
385 + 'results' => $results,
386 + ];
387 + }
388 +
151 389 public function install_and_activate_theme($theme_slug) {
152 390 require_once(ABSPATH . 'wp-admin/includes/class-wp-upgrader.php');
153 391 require_once(ABSPATH . 'wp-admin/includes/theme.php');
154 392 require_once(ABSPATH . 'wp-admin/includes/theme-install.php');
@@ -234,18 +472,26 @@
234 472
235 473 return ['success' => true];
236 474 }
237 475
238 - private function activate_plugin( $file ) {
476 + /**
477 + * @param string $file Plugin file.
478 + * @param bool $silent Skip the activation hooks. Correct ONLY when the plugin was
479 + * already set up and is merely being put back the way we found
480 + * it — see the call in `update()`. A fresh install must run them.
481 + */
482 + private function activate_plugin( $file, $silent = false ) {
239 483 if ( is_plugin_active( $file ) ) {
240 484 return true;
241 485 }
242 486
243 487 if ( Helper::current_user_can( 'activate_plugins' ) && is_plugin_inactive( $file ) ) {
244 - $result = activate_plugin( $file );
488 + $result = activate_plugin( $file, '', false, $silent );
245 489 if ( is_wp_error( $result ) ) {
246 490 return $result;
247 491 } else {
492 + $this->clear_known_activation_redirects();
493 +
248 494 return true;
249 495 }
250 496 }
251 497
@@ -251,8 +497,32 @@
251 497
252 498 return false;
253 499 }
254 500
501 + /**
502 + * Neutralise "getting started" redirect flags that popular dependency plugins
503 + * set in their own activation hooks (e.g. Elementor's
504 + * `elementor_activation_redirect` transient, Essential Addons' own
505 + * `eael_do_activation_redirect` transient + `eael_setup_wizard` option).
506 + *
507 + * Templately activates these plugins programmatically during FSI / single-
508 + * template import. Each plugin's own redirect check is guarded against
509 + * `DOING_AJAX`, so it never fires DURING the (AJAX-driven) import request —
510 + * but the flag survives past it and hijacks the user's NEXT ordinary
511 + * (non-AJAX) admin page load into that plugin's onboarding wizard instead of
512 + * wherever they actually navigated. Deleting an already-cleared flag is a
513 + * harmless no-op, so this is safe to call unconditionally after every
514 + * activation, regardless of which plugin was just activated.
515 + */
516 + private function clear_known_activation_redirects() {
517 + delete_transient( 'elementor_activation_redirect' ); // Elementor (free)
518 + delete_transient( 'eael_do_activation_redirect' ); // Essential Addons for Elementor
519 +
520 + if ( 'redirect' === get_option( 'eael_setup_wizard' ) ) {
521 + delete_option( 'eael_setup_wizard' );
522 + }
523 + }
524 +
255 525 private function activate_theme($theme_slug) {
256 526 if (get_option('stylesheet') == $theme_slug) {
257 527 // The theme is already active
258 528 return true;
@@ -270,7 +540,105 @@
270 540 }
271 541 }
272 542
273 543 return false;
544 + }
545 +
546 +
547 + /**
548 + * Ask any registered provisioning source for an installable pro-plugin archive.
549 + *
550 + * Returns null — meaning "not available", the only failure this feature has — when the
551 + * plugin is not one Templately may obtain, when the module supplying the seam is not
552 + * loaded, when the site is not connected, when the current user may not activate what
553 + * would be installed, or when no source answers.
554 + *
555 + * The activation capability is checked HERE as well as after installation: downloading
556 + * megabytes for a user who could never activate the result is wasted work and leaves a
557 + * plugin installed that nobody asked for.
558 + *
559 + * @param array $plugin Dependency descriptor.
560 + * @return string|null Absolute path to an archive, or null.
561 + */
562 + private function provision_pro_archive( array &$plugin ) {
563 + // `class_exists()` alone is NOT a "module enabled" test: Modules_Manager registers
564 + // the autoloader namespace for every DISCOVERED module, inactive ones included, so
565 + // the class loads even when the module never booted. What a disabled module does
566 + // not do is add its filter listener — and the dev mock depends on it, so it is
567 + // skipped too. No listener ⇒ nothing can answer ⇒ the pre-059 refusal stands
568 + // (FR-025), without spending the checks below.
569 + if ( ! class_exists( ProProvisioningModule::class ) || ! ProProvisioningModule::is_live() ) {
570 + Helper::log(
571 + sprintf( 'pro-provisioning: module not live, cannot obtain "%s"', $plugin['slug'] ?? '(no slug)' ),
572 + 'pro_plugin_provisioning',
573 + 'info'
574 + );
575 +
576 + return null;
577 + }
578 +
579 + $entry = ProProvisioningCatalog::find( $plugin );
580 +
581 + if ( null === $entry ) {
582 + // Not one of the plugins this feature can obtain. Ordinary, and the reason the
583 + // catalog exists — but indistinguishable from a failure without saying so.
584 + Helper::log(
585 + sprintf( 'pro-provisioning: "%s" is not in the obtainable catalog', $plugin['slug'] ?? '(no slug)' ),
586 + 'pro_plugin_provisioning',
587 + 'info'
588 + );
589 +
590 + return null;
591 + }
592 +
593 + // The catalog matched — possibly by slug alone. Everything after installation
594 + // (`is_plugin_inactive()`, `activate_plugin()`) keys on the plugin FILE, so it
595 + // must be the catalog's, not whatever the descriptor did or did not carry.
596 + $plugin['plugin_file'] = $entry['plugin_file'];
597 +
598 + if ( ! Helper::current_user_can( 'activate_plugins' ) ) {
599 + Helper::log(
600 + sprintf( 'pro-provisioning[%s]: user cannot activate_plugins', $entry['slug'] ),
601 + 'pro_plugin_provisioning',
602 + 'info'
603 + );
604 +
605 + return null;
606 + }
607 +
608 + $credential = ProProvisioningModule::credential();
609 +
610 + if ( '' === $credential ) {
611 + // No stored api_key: the site is not connected to Templately, so there is
612 + // nothing to present as proof of entitlement.
613 + Helper::log(
614 + sprintf( 'pro-provisioning[%s]: no stored credential — site not connected', $entry['slug'] ),
615 + 'pro_plugin_provisioning',
616 + 'info'
617 + );
618 +
619 + return null;
620 + }
621 +
622 + $archive = apply_filters( ProProvisioningModule::ARCHIVE_FILTER, null, [
623 + 'plugin_file' => $entry['plugin_file'],
624 + 'slug' => $entry['slug'],
625 + 'platform' => $entry['platform'],
626 + 'credential' => $credential,
627 + ] );
628 +
629 + if ( is_string( $archive ) && '' !== $archive && is_readable( $archive ) ) {
630 + return $archive;
631 + }
632 +
633 + // The source has already logged WHY it could not supply one; record that the
634 + // refusal is what the import will act on, so the two halves join up in the log.
635 + Helper::log(
636 + sprintf( 'pro-provisioning[%s]: no archive available — falling back to "install it yourself"', $entry['slug'] ),
637 + 'pro_plugin_provisioning',
638 + 'info'
639 + );
640 +
641 + return null;
274 642 }
275 643
276 644 }