config = []; $this->notices = []; } /** * Load hooks. * * @return void */ public function load_hooks() { add_action( 'admin_init', [ $this, 'admin_init' ] ); add_action( 'admin_footer', [ $this, 'admin_footer' ] ); add_action( 'admin_notices', [ $this, 'admin_notices' ] ); add_action( 'network_admin_notices', [ $this, 'admin_notices' ] ); add_action( 'wp_ajax_dependency_installer', [ $this, 'ajax_router' ] ); add_filter( 'http_request_args', [ $this, 'add_basic_auth_headers' ], 15, 2 ); add_filter( 'wp_dependency_notices', function( $notices, $slug ) { foreach ( array_keys( $notices ) as $key ) { if ( ! is_wp_error( $notices[ $key ] ) && $notices[ $key ]['slug'] === $slug ) { $notices[ $key ]['nonce'] = $this->config[ $slug ]['nonce']; } } return $notices; }, 10, 2 ); new \WP_Dismiss_Notice(); } /** * Let's get going. * First load data from wp-dependencies.json if present. * Then load hooks needed to run. * * @param string $caller Path to plugin or theme calling the framework. * * @return self */ public function run( $caller = false ) { $caller = ! $caller ? self::$caller : $caller; $config = $this->json_file_decode( $caller . '/wp-dependencies.json' ); if ( ! empty( $config ) ) { $this->register( $config, $caller ); } if ( ! empty( $this->config ) ) { $this->load_hooks(); } return $this; } /** * Decode JSON config data from a file. * * @param string $json_path File path to JSON config file. * * @return bool|array $config */ public function json_file_decode( $json_path ) { $config = []; if ( file_exists( $json_path ) ) { $config = file_get_contents( $json_path ); $config = json_decode( $config, true ); } return $config; } /** * Register dependencies (supports multiple instances). * * @param array $config JSON config as array. * @param string $caller Path to plugin or theme calling the framework. * * @return self */ public function register( $config, $caller = false ) { $source = ! self::$source ? basename( $caller ) : self::$source; foreach ( $config as $dependency ) { // Save a reference of current dependent plugin. $dependency['source'] = $source; $dependency['sources'][] = $source; $slug = $dependency['slug']; $dependency['nonce'] = \wp_create_nonce( 'wp-dependency-installer_' . $slug ); // Keep a reference of all dependent plugins. if ( isset( $this->config[ $slug ] ) ) { $dependency['sources'] = array_merge( $this->config[ $slug ]['sources'], $dependency['sources'] ); } // Update config. if ( ! isset( $this->config[ $slug ] ) || $this->is_required( $dependency ) ) { $this->config[ $slug ] = $dependency; } } return $this; } /** * Process the registered dependencies. */ private function apply_config() { foreach ( $this->config as $dependency ) { $download_link = null; $base = null; $uri = $dependency['uri']; $slug = $dependency['slug']; $uri_args = parse_url( $uri ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url $port = isset( $uri_args['port'] ) ? $uri_args['port'] : null; $api = isset( $uri_args['host'] ) ? $uri_args['host'] : null; $api = ! $port ? $api : "{$api}:{$port}"; $scheme = isset( $uri_args['scheme'] ) ? $uri_args['scheme'] : null; $scheme = null !== $scheme ? $scheme . '://' : 'https://'; $path = isset( $uri_args['path'] ) ? $uri_args['path'] : null; $owner_repo = str_replace( '.git', '', trim( $path, '/' ) ); switch ( $dependency['host'] ) { case 'github': $base = null === $api || 'github.com' === $api ? 'api.github.com' : $api; $download_link = "{$scheme}{$base}/repos/{$owner_repo}/zipball/{$dependency['branch']}"; break; case 'bitbucket': $base = null === $api || 'bitbucket.org' === $api ? 'bitbucket.org' : $api; $download_link = "{$scheme}{$base}/{$owner_repo}/get/{$dependency['branch']}.zip"; break; case 'gitlab': $base = null === $api || 'gitlab.com' === $api ? 'gitlab.com' : $api; $project_id = rawurlencode( $owner_repo ); $download_link = "{$scheme}{$base}/api/v4/projects/{$project_id}/repository/archive.zip"; $download_link = add_query_arg( 'sha', $dependency['branch'], $download_link ); break; case 'gitea': $download_link = "{$scheme}{$api}/api/v1/repos/{$owner_repo}/archive/{$dependency['branch']}.zip"; break; case 'wordpress': // phpcs:ignore WordPress.WP.CapitalPDangit.Misspelled $download_link = $this->get_dot_org_latest_download( basename( $owner_repo ) ); break; case 'direct': $download_link = filter_var( $uri, FILTER_VALIDATE_URL ); break; } /** * Allow filtering of download link for dependency configuration. * * @since 1.4.11 * * @param string $download_link Download link. * @param array $dependency Dependency configuration. */ $dependency['download_link'] = apply_filters( 'wp_dependency_download_link', $download_link, $dependency ); /** * Allow filtering of individual dependency config. * * @since 3.0.0 * * @param array $dependency Dependency configuration. */ $this->config[ $slug ] = apply_filters( 'wp_dependency_config', $dependency ); } } /** * Get lastest download link from WordPress API. * * @param string $slug Plugin slug. * @return string $download_link */ private function get_dot_org_latest_download( $slug ) { $download_link = get_site_transient( 'wpdi-' . md5( $slug ) ); if ( ! $download_link ) { $url = 'https://api.wordpress.org/plugins/info/1.1/'; $url = add_query_arg( [ 'action' => 'plugin_information', rawurlencode( 'request[slug]' ) => $slug, ], $url ); $response = wp_remote_get( $url ); $response = json_decode( wp_remote_retrieve_body( $response ) ); $download_link = empty( $response ) ? "https://downloads.wordpress.org/plugin/{$slug}.zip" : $response->download_link; set_site_transient( 'wpdi-' . md5( $slug ), $download_link, DAY_IN_SECONDS ); } return $download_link; } /** * Determine if dependency is active or installed. */ public function admin_init() { // Get the gears turning. $this->apply_config(); // Generate admin notices. foreach ( $this->config as $slug => $dependency ) { $is_required = $this->is_required( $dependency ); if ( $is_required ) { $this->modify_plugin_row( $slug ); } if ( ! wp_verify_nonce( $dependency['nonce'], 'wp-dependency-installer_' . $slug ) ) { return false; } // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf if ( $this->is_active( $slug ) ) { // Do nothing. } elseif ( $this->is_installed( $slug ) ) { if ( $is_required ) { $this->notices[] = $this->activate( $slug ); } else { $this->notices[] = $this->activate_notice( $slug ); } } else { if ( $is_required ) { $this->notices[] = $this->install( $slug ); } else { $this->notices[] = $this->install_notice( $slug ); } } /** * Allow filtering of admin notices. * * @since 3.0.0 * * @param array $notices admin notices. * @param string $slug plugin slug. */ $this->notices = apply_filters( 'wp_dependency_notices', $this->notices, $slug ); } } /** * Register jQuery AJAX. */ public function admin_footer() { ?> $method( $slug ); $message = is_wp_error( $response ) ? $response->get_error_message() : $response['message']; esc_html_e( $message ); } wp_die(); } /** * Check if a dependency is currently required. * * @param string|array $plugin Plugin dependency slug or config. * * @return boolean True if required. Default: False */ public function is_required( &$plugin ) { if ( empty( $this->config ) ) { return false; } if ( is_string( $plugin ) && isset( $this->config[ $plugin ] ) ) { $dependency = &$this->config[ $plugin ]; } else { $dependency = &$plugin; } if ( isset( $dependency['required'] ) ) { return true === $dependency['required'] || 'true' === $dependency['required']; } if ( isset( $dependency['optional'] ) ) { return false === $dependency['optional'] || 'false' === $dependency['optional']; } return false; } /** * Is dependency installed? * * @param string $slug Plugin slug. * * @return boolean */ public function is_installed( $slug ) { $plugins = get_plugins(); return isset( $plugins[ $slug ] ); } /** * Is dependency active? * * @param string $slug Plugin slug. * * @return boolean */ public function is_active( $slug ) { return is_plugin_active( $slug ); } /** * Install and activate dependency. * * @param string $slug Plugin slug. * * @return bool|array false or Message. */ public function install( $slug ) { if ( $this->is_installed( $slug ) || ! current_user_can( 'update_plugins' ) ) { return false; } $this->current_slug = $slug; add_filter( 'upgrader_source_selection', [ $this, 'upgrader_source_selection' ], 10, 2 ); $skin = new WP_Dependency_Installer_Skin( [ 'type' => 'plugin', 'nonce' => wp_nonce_url( $this->config[ $slug ]['download_link'] ), ] ); $upgrader = new Plugin_Upgrader( $skin ); $result = $upgrader->install( $this->config[ $slug ]['download_link'] ); if ( is_wp_error( $result ) ) { return [ 'status' => 'error', 'message' => $result->get_error_message(), ]; } if ( null === $result ) { return [ 'status' => 'error', 'message' => esc_html__( 'Plugin download failed' ), ]; } wp_cache_flush(); if ( $this->is_required( $slug ) ) { $result = $this->activate( $slug ); if ( ! is_wp_error( $result ) ) { return [ 'status' => 'updated', 'slug' => $slug, /* translators: %s: Plugin name */ 'message' => sprintf( esc_html__( '%s has been installed and activated.' ), $this->config[ $slug ]['name'] ), 'source' => $this->config[ $slug ]['source'], ]; } } if ( is_wp_error( $result ) || ( true !== $result && 'error' === $result['status'] ) ) { return $result; } return [ 'status' => 'updated', /* translators: %s: Plugin name */ 'message' => sprintf( esc_html__( '%s has been installed.' ), $this->config[ $slug ]['name'] ), 'source' => $this->config[ $slug ]['source'], ]; } /** * Get install plugin notice. * * @param string $slug Plugin slug. * * @return array Admin notice. */ public function install_notice( $slug ) { $dependency = $this->config[ $slug ]; return [ 'action' => 'install', 'slug' => $slug, /* translators: %s: Plugin name */ 'message' => sprintf( esc_html__( 'The %s plugin is recommended.' ), $dependency['name'] ), 'source' => $dependency['source'], ]; } /** * Activate dependency. * * @param string $slug Plugin slug. * * @return array Message. */ public function activate( $slug ) { if ( ! current_user_can( 'activate_plugins' ) ) { return new WP_Error( 'wpdi_activate_plugins', __( 'Current user cannot activate plugins.' ), $this->config[ $slug ]['name'] ); } // network activate only if on network admin pages. $result = is_network_admin() ? activate_plugin( $slug, null, true ) : activate_plugin( $slug ); if ( is_wp_error( $result ) ) { return [ 'status' => 'error', 'message' => $result->get_error_message(), ]; } return [ 'status' => 'updated', 'slug' => $slug, /* translators: %s: Plugin name */ 'message' => sprintf( esc_html__( '%s has been activated.' ), $this->config[ $slug ]['name'] ), 'source' => $this->config[ $slug ]['source'], ]; } /** * Get activate plugin notice. * * @param string $slug Plugin slug. * * @return array Admin notice. */ public function activate_notice( $slug ) { $dependency = $this->config[ $slug ]; return [ 'action' => 'activate', 'slug' => $slug, /* translators: %s: Plugin name */ 'message' => sprintf( esc_html__( 'Please activate the %s plugin.' ), $dependency['name'] ), 'source' => $dependency['source'], ]; } /** * Dismiss admin notice for a week. * * @return array Empty Message. */ public function dismiss() { return [ 'status' => 'updated', 'message' => '', ]; } /** * Correctly rename dependency for activation. * * @param string $source Path fo $source. * @param string $remote_source Path of $remote_source. * * @return string $new_source */ public function upgrader_source_selection( $source, $remote_source ) { $new_source = trailingslashit( $remote_source ) . dirname( $this->current_slug ); $this->move_dir( $source, $new_source, true ); return trailingslashit( $new_source ); } /** * Moves a directory from one location to another via the rename() PHP function. * If the renaming failed, falls back to copy_dir(). * * Assumes that WP_Filesystem() has already been called and setup. * * @since 6.2.0 * * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass. * * @param string $from Source directory. * @param string $to Destination directory. * @param bool $overwrite Overwrite destination. * Default is false. * @return bool|WP_Error True on success, False or WP_Error on failure. */ private function move_dir( $from, $to, $overwrite = false ) { global $wp_filesystem; if ( trailingslashit( strtolower( $from ) ) === trailingslashit( strtolower( $to ) ) ) { return new \WP_Error( 'source_destination_same_move_dir', __( 'The source and destination are the same.' ) ); } if ( $wp_filesystem->exists( $to ) ) { if ( ! $overwrite ) { return new \WP_Error( 'destination_already_exists_move_dir', __( 'The destination folder already exists.' ), $to ); } elseif ( ! $wp_filesystem->delete( $to, true ) ) { // Can't overwrite if the destination couldn't be deleted. return \WP_Error( 'destination_not_deleted_move_dir', __( 'The destination directory already exists and could not be removed.' ) ); } } $result = false; if ( 'direct' === $wp_filesystem->method ) { if ( $wp_filesystem->delete( $to, true ) ) { $result = @rename( $from, $to ); } } else { // Non-direct filesystems use some version of rename without a fallback. $result = $wp_filesystem->move( $from, $to, $overwrite ); } if ( $result ) { /* * When using an environment with shared folders, * there is a delay in updating the filesystem's cache. * * This is a known issue in environments with a VirtualBox provider. * * A 200ms delay gives time for the filesystem to update its cache, * prevents "Operation not permitted", and "No such file or directory" warnings. * * This delay is used in other projects, including Composer. * @link https://github.com/composer/composer/blob/main/src/Composer/Util/Platform.php#L228-L233 */ usleep( 200000 ); $this->wp_opcache_invalidate_directory( $to ); } if ( ! $result ) { if ( ! $wp_filesystem->is_dir( $to ) ) { if ( ! $wp_filesystem->mkdir( $to, FS_CHMOD_DIR ) ) { return new \WP_Error( 'mkdir_failed_move_dir', __( 'Could not create directory.' ), $to ); } } $result = copy_dir( $from, $to, [ basename( $to ) ] ); // Clear the source directory. if ( ! is_wp_error( $result ) ) { $wp_filesystem->delete( $from, true ); } } return $result; } /** * Attempts to clear the opcode cache for a directory of files. * * @since 6.2.0 * * @see wp_opcache_invalidate() * @link https://www.php.net/manual/en/function.opcache-invalidate.php * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass. * * @param string $dir The path to the directory for which the opcode cache is to be cleared. */ private function wp_opcache_invalidate_directory( $dir ) { global $wp_filesystem; if ( ! is_string( $dir ) || '' === trim( $dir ) ) { if ( WP_DEBUG ) { $error_message = sprintf( /* translators: %s: The function name. */ __( '%s expects a non-empty string.' ), 'wp_opcache_invalidate_directory()' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error trigger_error( $error_message ); } return; } $dirlist = $wp_filesystem->dirlist( $dir, false, true ); if ( empty( $dirlist ) ) { return; } /* * Recursively invalidate opcache of files in a directory. * * WP_Filesystem_*::dirlist() returns an array of file and directory information. * * This does not include a path to the file or directory. * To invalidate files within sub-directories, recursion is needed * to prepend an absolute path containing the sub-directory's name. * * @param array $dirlist Array of file/directory information from WP_Filesystem_Base::dirlist(), * with sub-directories represented as nested arrays. * @param string $path Absolute path to the directory. */ $invalidate_directory = function( $dirlist, $path ) use ( &$invalidate_directory ) { $path = trailingslashit( $path ); foreach ( $dirlist as $name => $details ) { if ( 'f' === $details['type'] ) { wp_opcache_invalidate( $path . $name, true ); continue; } if ( is_array( $details['files'] ) && ! empty( $details['files'] ) ) { $invalidate_directory( $details['files'], $path . $name ); } } }; $invalidate_directory( $dirlist, $dir ); } /** * Display admin notices / action links. * * @return bool/string false or Admin notice. */ public function admin_notices() { if ( ! current_user_can( 'update_plugins' ) ) { return false; } foreach ( $this->notices as $notice ) { $status = isset( $notice['status'] ) ? $notice['status'] : 'updated'; $source = isset( $notice['source'] ) ? $notice['source'] : __( 'Dependency' ); $class = esc_attr( $status ) . ' notice is-dismissible dependency-installer'; $label = esc_html( $this->get_dismiss_label( $source ) ); $message = ''; $action = ''; $dismissible = ''; if ( isset( $notice['message'] ) ) { $message = esc_html( $notice['message'] ); } if ( isset( $notice['action'] ) ) { $action = sprintf( ' %4$s Now » ', esc_attr( $notice['action'] ), esc_attr( $notice['slug'] ), esc_attr( $notice['nonce'] ), esc_html( ucfirst( $notice['action'] ) ) ); } if ( isset( $notice['slug'] ) ) { /** * Filters the dismissal timeout. * * @since 1.4.1 * * @param string|int '7' Default dismissal in days. * @param string $notice['source'] Plugin slug of calling plugin. * @return string|int Dismissal timeout in days. */ $timeout = apply_filters( 'wp_dependency_timeout', '7', $source ); $dependency = dirname( $notice['slug'] ); $dismissible = empty( $timeout ) ? '' : sprintf( 'dependency-installer-%1$s-%2$s', esc_attr( $dependency ), esc_attr( $timeout ) ); } if ( \WP_Dismiss_Notice::is_admin_notice_active( $dismissible ) ) { printf( '

[%3$s] %4$s%5$s

', esc_attr( $class ), esc_attr( $dismissible ), esc_html( $label ), esc_html( $message ), // $action is escaped above. // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $action ); } } } /** * Make modifications to plugin row. * * @param string $plugin_file Plugin file. */ private function modify_plugin_row( $plugin_file ) { add_filter( 'network_admin_plugin_action_links_' . $plugin_file, [ $this, 'unset_action_links' ], 10, 2 ); add_filter( 'plugin_action_links_' . $plugin_file, [ $this, 'unset_action_links' ], 10, 2 ); add_action( 'after_plugin_row_' . $plugin_file, [ $this, 'modify_plugin_row_elements' ] ); } /** * Unset plugin action links so required plugins can't be removed or deactivated. * * @param array $actions Action links. * @param string $plugin_file Plugin file. * * @return mixed */ public function unset_action_links( $actions, $plugin_file ) { /** * Allow to remove required plugin action links. * * @since 3.0.0 * * @param bool $unset remove default action links. */ if ( apply_filters( 'wp_dependency_unset_action_links', true ) ) { if ( isset( $actions['delete'] ) ) { unset( $actions['delete'] ); } if ( isset( $actions['deactivate'] ) ) { unset( $actions['deactivate'] ); } } /** * Allow to display of requied plugin label. * * @since 3.0.0 * * @param bool $display show required plugin label. */ if ( apply_filters( 'wp_dependency_required_label', true ) ) { /* translators: %s: opening and closing span tags */ $actions = array_merge( [ 'required-plugin' => sprintf( esc_html__( '%1$sRequired Plugin%2$s' ), '', '' ) ], $actions ); } return $actions; } /** * Modify the plugin row elements. * * @param string $plugin_file Plugin file. * * @return void */ public function modify_plugin_row_elements( $plugin_file ) { print ''; } /** * Get formatted string of dependent plugins. * * @param string $plugin_file Plugin file. * * @return string $dependents */ private function get_dependency_sources( $plugin_file ) { // Remove empty values from $sources. $sources = array_filter( $this->config[ $plugin_file ]['sources'] ); $sources = array_map( [ $this, 'get_dismiss_label' ], $sources ); $sources = implode( ', ', $sources ); return $sources; } /** * Get formatted source string for text usage. * * @param string $source plugin source. * * @return string friendly plugin name. */ private function get_dismiss_label( $source ) { $label = str_replace( '-', ' ', $source ); $label = ucwords( $label ); $label = str_ireplace( 'wp ', 'WP ', $label ); /** * Filters the dismissal notice label * * @since 3.0.0 * * @param string $label Default dismissal notice string. * @param string $source Plugin slug of calling plugin. * @return string Dismissal notice string. */ return apply_filters( 'wp_dependency_dismiss_label', $label, $source ); } /** * Get the configuration. * * @since 1.4.11 * * @param string $slug Plugin slug. * @param string $key Dependency key. * * @return mixed|array The configuration. */ public function get_config( $slug = '', $key = '' ) { if ( empty( $slug ) && empty( $key ) ) { return $this->config; } elseif ( empty( $key ) ) { return isset( $this->config[ $slug ] ) ? $this->config[ $slug ] : null; } else { return isset( $this->config[ $slug ][ $key ] ) ? $this->config[ $slug ][ $key ] : null; } } /** * Add Basic Auth headers for authentication. * * @param array $args HTTP header args. * @param string $url URL. * * @return array $args */ public function add_basic_auth_headers( $args, $url ) { if ( null === $this->current_slug ) { return $args; } $package = $this->config[ $this->current_slug ]; $host = $package['host']; $token = empty( $package['token'] ) ? false : $package['token']; if ( $token && $url === $package['download_link'] ) { if ( 'bitbucket' === $host ) { // Bitbucket token must be in the form of 'username:password'. // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode $args['headers']['Authorization'] = 'Basic ' . base64_encode( $token ); } if ( 'github' === $host || 'gitea' === $host ) { $args['headers']['Authorization'] = 'token ' . $token; } if ( 'gitlab' === $host ) { $args['headers']['Authorization'] = 'Bearer ' . $token; } } // dot org should not have auth header. // phpcs:ignore WordPress.WP.CapitalPDangit.Misspelled if ( 'wordpress' === $host ) { unset( $args['headers']['Authorization'] ); } remove_filter( 'http_request_args', [ $this, 'add_basic_auth_headers' ] ); return $args; } } }