client = $client; $this->job_id = wp_generate_password( 12, false ); } public function get_job_id(): string { return $this->job_id; } /** * Install or upgrade the host plugin from a signed package URL. * * @param string $package_url Signed URL (from /software/get-package). * @param string $target_version The version the URL is expected to deliver. * @param string|null $current_version Currently installed version, used to * distinguish update vs. rollback in * the log and audit metadata. * * @return array|WP_Error On success, an array with the captured log, * status, and version metadata. WP_Error on failure. */ public function install_from_url( string $package_url, string $target_version, ?string $current_version = null ) { if ( ! $this->client->isPlugin() ) { return new WP_Error( 'sdk-only-plugins-supported', __( 'Only plugin packages can be installed via the SDK installer.', 'storeengine-sdk' ), [ 'status' => 400 ] ); } if ( ! current_user_can( 'install_plugins' ) || ! current_user_can( 'activate_plugins' ) ) { return new WP_Error( 'sdk-insufficient-capabilities', __( 'You do not have permission to install plugins.', 'storeengine-sdk' ), [ 'status' => 403 ] ); } // Supply-chain hardening: only install packages served from the // vendor's own license-server host (or a subdomain of its registrable // domain). The package URL originates from the /software/get-package // response, so pinning the host here prevents a tampered or MITM'd // response from redirecting the install to an attacker-controlled ZIP // before it ever reaches WP's Plugin_Upgrader. See WP.org review // "remote_controlled_update". $trusted_host = $this->is_trusted_package_url( $package_url ); if ( is_wp_error( $trusted_host ) ) { return $trusted_host; } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/misc.php'; require_once ABSPATH . 'wp-admin/includes/plugin.php'; require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; require_once ABSPATH . 'wp-admin/includes/class-plugin-upgrader.php'; // Force-load the filesystem. If FS_METHOD isn't 'direct', this fails // without prompting (the skin's request_filesystem_credentials always // returns true). if ( ! WP_Filesystem() ) { return new WP_Error( 'sdk-fs-unavailable', __( 'WordPress could not initialize the filesystem. Set FS_METHOD to "direct" or ensure the web server can write to wp-content/plugins.', 'storeengine-sdk' ), [ 'status' => 500 ] ); } $basename = $this->client->getBasename(); $slug = $this->client->getSlug(); $was_active = is_plugin_active( $basename ); $is_rollback = $current_version && version_compare( $target_version, $current_version, '<' ); // Suspend the cache and bump time limits while we work — install can // take 10–30s on slow hosts and PHP-FPM default is often 30s. wp_suspend_cache_addition( true ); if ( function_exists( 'set_time_limit' ) ) { @set_time_limit( 300 ); // phpcs:ignore } // Defensive load — see Updater.php::require_sibling() for context. if ( ! class_exists( 'SE_License_SDK_Upgrader_Skin', false ) ) { $skin_path = __DIR__ . DIRECTORY_SEPARATOR . 'SE_License_SDK_Upgrader_Skin.php'; if ( is_readable( $skin_path ) ) { require_once $skin_path; } } $skin = new SE_License_SDK_Upgrader_Skin(); $upgrader = new Plugin_Upgrader( $skin ); // Tag the run so other plugins (and our own previous_version // recorder) can see what kind of operation this is. $hook_extra = [ 'type' => 'plugin', 'action' => $is_rollback ? 'install' : 'update', 'plugin' => $basename, 'temp_backup' => [ 'slug' => $slug, 'src' => WP_PLUGIN_DIR, 'dir' => 'plugins', ], 'storeengine_sdk' => [ 'job_id' => $this->job_id, 'target_version' => $target_version, 'current_version' => $current_version, 'is_rollback' => $is_rollback, 'slug' => $slug, ], ]; /** * Lets the consumer pre-validate or veto the install. * * @param true|WP_Error $proceed Return WP_Error to abort. * @param array $hook_extra */ $proceed = apply_filters( $this->client->getHookName( 'pre_install' ), true, $hook_extra ); if ( is_wp_error( $proceed ) ) { wp_suspend_cache_addition( false ); return $proceed; } $run_args = [ 'package' => $package_url, 'destination' => WP_PLUGIN_DIR, 'clear_destination' => true, 'clear_working' => true, 'abort_if_destination_exists' => false, 'hook_extra' => $hook_extra, ]; // Mirror `Plugin_Upgrader::deactivate_plugin_before_upgrade()` — but // only when we already know the plugin is active. Touching an // actively-loaded plugin file on Windows fails silently (the file is // open and cannot be replaced), which leaks as "install reported // success but nothing actually changed". The post-install // reactivation step below restores it. if ( $was_active && ! is_plugin_inactive( $basename ) ) { deactivate_plugins( [ $basename ], true ); } // Pin the install destination to this plugin's slug regardless of // what the zip's top folder is named. Older deployment uploads // (predating the upload-time normalization in license-management's // versions-controller.php) can contain a top folder like // "{slug}-{version}/" instead of "{slug}/". WP_Upgrader builds the // destination from that top-folder basename, so without this fix a // rollback to one of those older zips installs into a NEW sibling // folder (e.g. wp-content/plugins/{slug}-1.0/) and leaves the // currently-installed plugin folder untouched — install reports // success but the user sees no change on disk. // // The filter is scoped via $hook_extra['storeengine_sdk']['slug'] // so concurrent SDK consumers don't normalize each other's installs. $expected_slug = $slug; $normalize_filter = static function ( $source, $remote_source, $upgrader_inst, $hook_extra_passed ) use ( $expected_slug, $skin ) { global $wp_filesystem; if ( empty( $hook_extra_passed['storeengine_sdk']['slug'] ) ) { return $source; } if ( $hook_extra_passed['storeengine_sdk']['slug'] !== $expected_slug ) { return $source; } $source = trailingslashit( $source ); $actual = trim( basename( untrailingslashit( $source ) ), '/' ); $expected_path = trailingslashit( $remote_source ) . $expected_slug; if ( $actual === $expected_slug ) { return $source; } // Old leftovers from a previous failed install could exist at // the target path inside the working dir; clear before move. if ( $wp_filesystem->exists( $expected_path ) ) { $wp_filesystem->delete( $expected_path, true ); } if ( ! $wp_filesystem->move( untrailingslashit( $source ), $expected_path ) ) { return new WP_Error( 'sdk-rename-source-failed', sprintf( /* translators: 1: actual folder name in zip, 2: expected plugin slug */ __( 'Could not normalize package folder from "%1$s" to "%2$s".', 'storeengine-sdk' ), $actual, $expected_slug ) ); } if ( method_exists( $skin, 'feedback' ) ) { $skin->feedback( sprintf( /* translators: 1: actual folder name in zip, 2: expected plugin slug */ __( 'Renamed package folder "%1$s" → "%2$s" to match installed plugin.', 'storeengine-sdk' ), $actual, $expected_slug ) ); } return trailingslashit( $expected_path ); }; add_filter( 'upgrader_source_selection', $normalize_filter, 10, 4 ); try { $result = $upgrader->run( $run_args ); } finally { remove_filter( 'upgrader_source_selection', $normalize_filter, 10 ); } // Restore caching. wp_suspend_cache_addition( false ); $messages = $skin->get_messages(); if ( is_wp_error( $result ) ) { $this->persist_log( 'failed', $messages, $target_version, $current_version, $is_rollback ); return new WP_Error( 'sdk-install-failed', $result->get_error_message() ?: __( 'Plugin installer reported an error.', 'storeengine-sdk' ), [ 'status' => 500, 'log' => $messages ] ); } if ( false === $result || null === $result ) { $err = $skin->get_last_error() ?: __( 'Installer returned no result.', 'storeengine-sdk' ); $this->persist_log( 'failed', $messages, $target_version, $current_version, $is_rollback ); return new WP_Error( 'sdk-install-failed', $err, [ 'status' => 500, 'log' => $messages ] ); } // Reactivate the plugin if it was active before the install. The // upgrader leaves it deactivated by default to avoid running new // activation hooks against the old database state — that's fine // because we're either upgrading our own code or putting it back. $reactivated = true; if ( $was_active && is_plugin_inactive( $basename ) ) { $activated = activate_plugin( $basename ); if ( is_wp_error( $activated ) ) { $reactivated = false; $messages[] = [ 'level' => 'warning', 'message' => sprintf( /* translators: %s: WP error message */ __( 'Installed but could not reactivate the plugin: %s', 'storeengine-sdk' ), $activated->get_error_message() ), 'time' => time(), ]; } } $this->persist_log( 'succeeded', $messages, $target_version, $current_version, $is_rollback ); /** * Fires after a successful SDK-driven install/update/rollback. * * @param string $target_version * @param string|null $current_version * @param bool $is_rollback * @param array $messages */ do_action( $this->client->getHookName( 'post_install' ), $target_version, $current_version, $is_rollback, $messages ); return [ 'job_id' => $this->job_id, 'status' => 'succeeded', 'target_version' => $target_version, 'current_version' => $current_version, 'is_rollback' => $is_rollback, 'reactivated' => $reactivated, 'log' => $messages, ]; } /** * Validate that a package URL is safe to install from. * * Requires HTTPS and pins the host to the vendor's license server (or a * subdomain of its registrable domain). The allow-list is filterable via * the client-scoped `trusted_package_hosts` hook so a vendor that serves * packages from a dedicated download/CDN host can add it without widening * trust to arbitrary URLs. * * @param string $package_url URL returned by /software/get-package. * * @return true|WP_Error True when trusted, WP_Error otherwise. */ private function is_trusted_package_url( string $package_url ) { $parsed = wp_parse_url( $package_url ); $scheme = isset( $parsed['scheme'] ) ? strtolower( $parsed['scheme'] ) : ''; $host = isset( $parsed['host'] ) ? strtolower( trim( $parsed['host'], '.' ) ) : ''; if ( 'https' !== $scheme || '' === $host ) { return new WP_Error( 'sdk-untrusted-package-url', __( 'The update package URL is not a valid HTTPS URL and was rejected for safety.', 'storeengine-sdk' ), [ 'status' => 400 ] ); } $server_host = strtolower( $this->client->get_license_server_host() ); $allowed = []; if ( '' !== $server_host ) { $allowed[] = $server_host; // Allow subdomains of the license server's registrable domain so a // dedicated download/CDN host (e.g. downloads.example.com) works // without trusting hosts outside the vendor's own domain. $labels = explode( '.', $server_host ); if ( count( $labels ) >= 2 ) { $allowed[] = implode( '.', array_slice( $labels, -2 ) ); } } /** * Filters the hosts trusted to serve update packages. * * Return an array of bare host names (e.g. 'downloads.example.com'). * A package host matches if it equals an entry or is a subdomain of it. * * @param string[] $allowed Trusted hosts. * @param string $package_url The package URL being validated. */ $allowed = apply_filters( $this->client->getHookName( 'trusted_package_hosts' ), $allowed, $package_url ); $allowed = array_unique( array_filter( array_map( 'strtolower', (array) $allowed ) ) ); foreach ( $allowed as $candidate ) { if ( $host === $candidate ) { return true; } $suffix = '.' . $candidate; if ( strlen( $host ) > strlen( $suffix ) && substr( $host, -strlen( $suffix ) ) === $suffix ) { return true; } } return new WP_Error( 'sdk-untrusted-package-host', sprintf( /* translators: %s: the host name returned in the package URL */ __( 'The update package host "%s" is not a trusted vendor host. Installation was aborted to protect against a tampered or redirected download.', 'storeengine-sdk' ), $host ), [ 'status' => 400 ] ); } /** * Persist the captured log so the UI can re-read it later (esp. for * post-mortem on a failed install where the React state was lost). */ private function persist_log( string $status, array $messages, string $target_version, ?string $current_version, bool $is_rollback ): void { set_transient( $this->log_transient_key(), [ 'job_id' => $this->job_id, 'status' => $status, 'target_version' => $target_version, 'current_version' => $current_version, 'is_rollback' => $is_rollback, 'finished_at' => time(), 'messages' => $messages, ], self::LOG_TTL ); } private function log_transient_key(): string { return $this->client->getHookName( 'last_install_log' ); } /** * Look up the most recent install log written by any job for this client. */ public function get_last_log(): ?array { $value = get_transient( $this->log_transient_key() ); return is_array( $value ) ? $value : null; } } // End of file SE_License_SDK_Install_Job.php.