File: /var/www/html/wp-content/plugins/wp-graphql/src/Admin/Extensions/Extensions.php
<?php
namespace WPGraphQL\Admin\Extensions;
use WP_REST_Request;
use WP_REST_Response;
/**
* Class Extensions
*
* @package WPGraphQL\Admin\Extensions
*
* phpcs:disable -- For phpstan type hinting
* @phpstan-import-type ExtensionAuthor from \WPGraphQL\Admin\Extensions\Registry
* @phpstan-import-type Extension from \WPGraphQL\Admin\Extensions\Registry
*
* @phpstan-type PopulatedExtension array{
* name: non-empty-string,
* description: non-empty-string,
* plugin_url: non-empty-string,
* support_url: non-empty-string,
* documentation_url: non-empty-string,
* repo_url?: string,
* author: ExtensionAuthor,
* installed: bool,
* active: bool,
* settings_path?: string,
* settings_url?: string,
* }
* phpcs:enable
*/
final class Extensions {
/**
* The list of extensions.
*
* Filtered by `graphql_get_extensions`.
*
* @var PopulatedExtension[]
*/
private array $extensions;
/**
* Whether the JavaScript build assets are available.
*/
private bool $build_assets_available;
/**
* Initialize Extensions functionality for WPGraphQL.
*/
public function init(): void {
$this->build_assets_available = file_exists( WPGRAPHQL_PLUGIN_DIR . 'build/extensions.asset.php' );
add_action( 'admin_menu', [ $this, 'register_admin_page' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );
}
/**
* Register the admin page for extensions.
*/
public function register_admin_page(): void {
add_submenu_page(
'graphiql-ide',
__( 'WPGraphQL Extensions', 'wp-graphql' ),
__( 'Extensions', 'wp-graphql' ),
'manage_options',
'wpgraphql-extensions',
[ $this, 'render_admin_page' ]
);
}
/**
* Render the admin page content.
*
* When build assets are missing, we show helpful instructions instead of
* a broken interface.
*/
public function render_admin_page(): void {
echo '<div class="wrap">';
echo '<h1>' . esc_html( get_admin_page_title() ) . '</h1>';
if ( ! $this->build_assets_available ) {
$message = sprintf(
/* translators: 1: npm ci command, 2: npm run build command, 3: releases URL */
__( 'The Extensions page requires JavaScript assets that need to be built. Please run %1$s followed by %2$s in the plugin directory, or <a href="%3$s" target="_blank">download a release</a> that includes pre-built assets.', 'wp-graphql' ),
'<code>npm ci</code>',
'<code>npm run build</code>',
'https://github.com/wp-graphql/wp-graphql/releases'
);
echo '<div class="notice notice-warning inline" style="margin-top: 20px;"><p>' . wp_kses_post( $message ) . '</p></div>';
} else {
echo '<div style="margin-top: 20px;" id="wpgraphql-extensions"></div>';
}
echo '</div>';
}
/**
* Enqueue the necessary scripts and styles for the extensions page.
*
* The /build directory is gitignored and only generated via `npm run build`.
* Users who install via WordPress.org or GitHub releases have pre-built assets,
* but those who clone the repo or install via Composer need to build manually.
* We check for asset existence to prevent fatal errors in development environments.
*
* @param string $hook_suffix The current admin page.
*/
public function enqueue_scripts( $hook_suffix ): void {
if ( 'graphql_page_wpgraphql-extensions' !== $hook_suffix ) {
return;
}
$asset_path = WPGRAPHQL_PLUGIN_DIR . 'build/extensions.asset.php';
// Bail if build assets don't exist (e.g., dev install without running npm build)
if ( ! file_exists( $asset_path ) ) {
return;
}
// phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable -- Path is constructed from WPGRAPHQL_PLUGIN_DIR constant + hardcoded string, validated with file_exists()
$asset_file = include $asset_path;
wp_enqueue_style(
'wpgraphql-extensions',
WPGRAPHQL_PLUGIN_URL . 'build/extensions.css',
[ 'wp-components' ],
$asset_file['version']
);
wp_enqueue_script(
'wpgraphql-extensions',
WPGRAPHQL_PLUGIN_URL . 'build/extensions.js',
$asset_file['dependencies'],
$asset_file['version'],
true
);
// Set up script translations for i18n
wp_set_script_translations( 'wpgraphql-extensions', 'wp-graphql', WPGRAPHQL_PLUGIN_DIR . 'languages' );
wp_localize_script(
'wpgraphql-extensions',
'wpgraphqlExtensions',
[
'nonce' => wp_create_nonce( 'wp_rest' ),
'graphqlEndpoint' => trailingslashit( site_url() ) . 'index.php?' . graphql_get_endpoint(),
'extensions' => $this->get_extensions(),
'pluginsInstalled' => $this->get_installed_plugins(),
]
);
}
/**
* Register custom REST API routes.
*/
public function register_rest_routes(): void {
register_rest_route(
'wp/v2',
'/plugins/(?P<plugin>.+)',
[
'methods' => 'PUT',
'callback' => [ $this, 'activate_plugin' ],
'permission_callback' => static function () {
return current_user_can( 'activate_plugins' );
},
'args' => [
'plugin' => [
'required' => true,
'validate_callback' => static function ( $param, $request, $key ) {
return is_string( $param );
},
],
],
]
);
}
/**
* Activate a plugin.
*
* @param \WP_REST_Request<array{plugin:string}> $request The REST request.
*
* @return \WP_REST_Response The REST response.
*/
public function activate_plugin( WP_REST_Request $request ): WP_REST_Response {
$plugin = (string) $request->get_param( 'plugin' );
$result = activate_plugin( $plugin );
if ( is_wp_error( $result ) ) {
return new WP_REST_Response(
[
'status' => 'error',
'message' => $result->get_error_message(),
],
500
);
}
return new WP_REST_Response(
[
'status' => 'active',
'plugin' => $plugin,
],
200
);
}
/**
* Get the list of installed plugins
*
* @return array<string,array{
* is_active: bool,
* name: string,
* description: string,
* author: string,
* }> List of installed plugins, keyed by the plugin slug.
*/
private function get_installed_plugins(): array {
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugins = get_plugins();
$active_plugins = get_option( 'active_plugins' );
$installed_plugins = [];
foreach ( $plugins as $plugin_path => $plugin_info ) {
$slug = dirname( $plugin_path );
$installed_plugins[ $slug ] = [
'is_active' => in_array( $plugin_path, $active_plugins, true ),
'name' => $plugin_info['Name'],
'description' => $plugin_info['Description'],
'author' => $plugin_info['Author'],
];
}
return $installed_plugins;
}
/**
* Sanitizes extension values before they are used.
*
* @param array<string,mixed> $extension The extension to sanitize.
* @return array{
* name: string|null,
* description: string|null,
* plugin_url: string|null,
* support_url: string|null,
* documentation_url: string|null,
* repo_url: string|null,
* author: array{
* name: string|null,
* homepage: string|null,
* },
* }
*/
private function sanitize_extension( array $extension ): array {
return [
'name' => ! empty( $extension['name'] ) ? sanitize_text_field( $extension['name'] ) : null,
'description' => ! empty( $extension['description'] ) ? sanitize_text_field( $extension['description'] ) : null,
'plugin_url' => ! empty( $extension['plugin_url'] ) ? esc_url_raw( $extension['plugin_url'] ) : null,
'support_url' => ! empty( $extension['support_url'] ) ? esc_url_raw( $extension['support_url'] ) : null,
'documentation_url' => ! empty( $extension['documentation_url'] ) ? esc_url_raw( $extension['documentation_url'] ) : null,
'repo_url' => ! empty( $extension['repo_url'] ) ? esc_url_raw( $extension['repo_url'] ) : null,
'author' => [
'name' => ! empty( $extension['author']['name'] ) ? sanitize_text_field( $extension['author']['name'] ) : null,
'homepage' => ! empty( $extension['author']['homepage'] ) ? esc_url_raw( $extension['author']['homepage'] ) : null,
],
];
}
/**
* Validate an extension.
*
* Sanitization ensures that the values are correctly types, so we just need to check if the required fields are present.
*
* @param array<string,mixed> $extension The extension to validate.
*
* @return true|\WP_Error True if the extension is valid, otherwise an error.
*
* @phpstan-assert-if-true Extension $extension
*/
public function is_valid_extension( array $extension ) {
$error_code = 'invalid_extension';
// translators: First placeholder is the extension name. Second placeholder is the property that is missing from the extension.
$error_message = __( 'Invalid extension %1$s is missing a valid value for %2$s.', 'wp-graphql' );
// First handle the name field, since we'll use it in other error messages.
if ( empty( $extension['name'] ) ) {
return new \WP_Error( $error_code, esc_html__( 'Invalid extension. All extensions must have a `name`.', 'wp-graphql' ) );
}
// Handle the Top-Level fields.
$required_fields = [
'description',
'plugin_url',
'support_url',
'documentation_url',
];
foreach ( $required_fields as $property ) {
if ( empty( $extension[ $property ] ) ) {
return new \WP_Error(
$error_code,
sprintf( $error_message, $extension['name'], $property )
);
}
}
// Ensure Author has the required name field.
if ( empty( $extension['author']['name'] ) ) {
return new \WP_Error(
$error_code,
sprintf( $error_message, $extension['name'], 'author.name' )
);
}
return true;
}
/**
* Populate the extensions list with installation data.
*
* @param Extension[] $extensions The extensions to populate.
*
* @return PopulatedExtension[] The populated extensions.
*/
private function populate_installation_data( $extensions ): array {
$installed_plugins = $this->get_installed_plugins();
$populated_extensions = [];
foreach ( $extensions as $extension ) {
$slug = basename( rtrim( $extension['plugin_url'], '/' ) );
$extension['installed'] = false;
$extension['active'] = false;
// If the plugin is installed, populate the installation data.
if ( isset( $installed_plugins[ $slug ] ) ) {
$extension['installed'] = true;
$extension['active'] = $installed_plugins[ $slug ]['is_active'];
if ( ! empty( $installed_plugins[ $slug ]['author'] ) ) {
$extension['author']['name'] = $installed_plugins[ $slug ]['author'];
}
}
// @todo Where does this come from?
if ( isset( $extension['settings_path'] ) && true === $extension['active'] ) {
$extension['settings_url'] = is_multisite() && is_network_admin()
? network_admin_url( $extension['settings_path'] )
: admin_url( $extension['settings_path'] );
}
$populated_extensions[] = $extension;
}
/**
* Sort the extensions by the following criteria:
* 1. Plugins grouped by WordPress.org plugins first, non WordPress.org plugins after
* 2. Sort by plugin name in alphabetical order within the above groups, prioritizing "WPGraphQL" authored plugins
*/
usort(
$populated_extensions,
static function ( $a, $b ) {
if ( false !== strpos( $a['plugin_url'], 'wordpress.org' ) && false === strpos( $b['plugin_url'], 'wordpress.org' ) ) {
return -1;
}
if ( false === strpos( $a['plugin_url'], 'wordpress.org' ) && false !== strpos( $b['plugin_url'], 'wordpress.org' ) ) {
return 1;
}
if ( ! empty( $a['author']['name'] ) && ( 'WPGraphQL' === $a['author']['name'] && ( ! empty( $b['author']['name'] ) && 'WPGraphQL' !== $b['author']['name'] ) ) ) {
return -1;
}
if ( ! empty( $a['author']['name'] ) && 'WPGraphQL' !== $a['author']['name'] && ( ! empty( $b['author']['name'] ) && 'WPGraphQL' === $b['author']['name'] ) ) {
return 1;
}
return strcasecmp( $a['name'], $b['name'] );
}
);
return $populated_extensions;
}
/**
* Get the list of WPGraphQL extensions.
*
* @return PopulatedExtension[] The list of extensions.
*/
public function get_extensions(): array {
if ( ! isset( $this->extensions ) ) {
// @todo Replace with a call to the WPGraphQL server.
$extensions = Registry::get_extensions();
/**
* Filter the list of extensions, allowing other plugins to add or remove extensions.
*
* @see Admin\Extensions\Registry::get_extensions() for the correct format of the extensions.
*
* @param array<string,Extension> $extensions The list of extensions.
*/
$extensions = apply_filters( 'graphql_get_extensions', $extensions );
$valid_extensions = [];
foreach ( $extensions as $extension ) {
$sanitized = $this->sanitize_extension( $extension );
if ( true === $this->is_valid_extension( $sanitized ) ) {
$valid_extensions[] = $sanitized;
}
}
// If we have valid extensions, populate the installation data.
if ( ! empty( $valid_extensions ) ) {
$valid_extensions = $this->populate_installation_data( $valid_extensions );
}
$this->extensions = $valid_extensions;
}
return $this->extensions;
}
}