PluginProbe
WPGraphQL / trunk
WPGraphQL vtrunk
2.22.3 2.22.2 2.22.1 2.22.0 2.21.1 2.21.0 2.20.0 2.19.0 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.1 2.14.0 2.13.0 2.2.0 2.3.0 2.3.3 2.3.6 2.3.8 2.5.0 2.5.1 2.5.2 2.5.3 All 177 releases
wp-graphql / src / Admin / Extensions / Extensions.php

Extensions.php in WPGraphQL trunk, at src/Admin/Extensions/Extensions.php

427 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPGraphQL\Admin\Extensions;
4
5 use WP_REST_Request;
6 use WP_REST_Response;
7
8 /**
9 * Class Extensions
10 *
11 * @package WPGraphQL\Admin\Extensions
12 *
13 * phpcs:disable -- For phpstan type hinting
14 * @phpstan-import-type ExtensionAuthor from \WPGraphQL\Admin\Extensions\Registry
15 * @phpstan-import-type Extension from \WPGraphQL\Admin\Extensions\Registry
16 *
17 * @phpstan-type PopulatedExtension array{
18 * name: non-empty-string,
19 * description: non-empty-string,
20 * plugin_url: non-empty-string,
21 * support_url: non-empty-string,
22 * documentation_url: non-empty-string,
23 * repo_url?: string,
24 * author: ExtensionAuthor,
25 * installed: bool,
26 * active: bool,
27 * settings_path?: string,
28 * settings_url?: string,
29 * }
30 * phpcs:enable
31 */
32 final class Extensions {
33 /**
34 * The list of extensions.
35 *
36 * Filtered by `graphql_get_extensions`.
37 *
38 * @var PopulatedExtension[]
39 */
40 private array $extensions;
41
42 /**
43 * Whether the JavaScript build assets are available.
44 */
45 private bool $build_assets_available;
46
47 /**
48 * Initialize Extensions functionality for WPGraphQL.
49 */
50 public function init(): void {
51 $this->build_assets_available = file_exists( WPGRAPHQL_PLUGIN_DIR . 'build/extensions.asset.php' );
52
53 add_action( 'admin_menu', [ $this, 'register_admin_page' ] );
54 add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
55 add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );
56 }
57
58 /**
59 * Register the admin page for extensions.
60 */
61 public function register_admin_page(): void {
62 add_submenu_page(
63 'graphiql-ide',
64 __( 'WPGraphQL Extensions', 'wp-graphql' ),
65 __( 'Extensions', 'wp-graphql' ),
66 'manage_options',
67 'wpgraphql-extensions',
68 [ $this, 'render_admin_page' ]
69 );
70 }
71
72 /**
73 * Render the admin page content.
74 *
75 * When build assets are missing, we show helpful instructions instead of
76 * a broken interface.
77 */
78 public function render_admin_page(): void {
79 echo '<div class="wrap">';
80 echo '<h1>' . esc_html( get_admin_page_title() ) . '</h1>';
81
82 if ( ! $this->build_assets_available ) {
83 $message = sprintf(
84 /* translators: 1: npm ci command, 2: npm run build command, 3: releases URL */
85 __( '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' ),
86 '<code>npm ci</code>',
87 '<code>npm run build</code>',
88 'https://github.com/wp-graphql/wp-graphql/releases'
89 );
90 echo '<div class="notice notice-warning inline" style="margin-top: 20px;"><p>' . wp_kses_post( $message ) . '</p></div>';
91 } else {
92 echo '<div style="margin-top: 20px;" id="wpgraphql-extensions"></div>';
93 }
94
95 echo '</div>';
96 }
97
98 /**
99 * Enqueue the necessary scripts and styles for the extensions page.
100 *
101 * The /build directory is gitignored and only generated via `npm run build`.
102 * Users who install via WordPress.org or GitHub releases have pre-built assets,
103 * but those who clone the repo or install via Composer need to build manually.
104 * We check for asset existence to prevent fatal errors in development environments.
105 *
106 * @param string $hook_suffix The current admin page.
107 */
108 public function enqueue_scripts( $hook_suffix ): void {
109 if ( 'graphql_page_wpgraphql-extensions' !== $hook_suffix ) {
110 return;
111 }
112
113 $asset_path = WPGRAPHQL_PLUGIN_DIR . 'build/extensions.asset.php';
114
115 // Bail if build assets don't exist (e.g., dev install without running npm build)
116 if ( ! file_exists( $asset_path ) ) {
117 return;
118 }
119
120 // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable -- Path is constructed from WPGRAPHQL_PLUGIN_DIR constant + hardcoded string, validated with file_exists()
121 $asset_file = include $asset_path;
122
123 wp_enqueue_style(
124 'wpgraphql-extensions',
125 WPGRAPHQL_PLUGIN_URL . 'build/extensions.css',
126 [ 'wp-components' ],
127 $asset_file['version']
128 );
129
130 wp_enqueue_script(
131 'wpgraphql-extensions',
132 WPGRAPHQL_PLUGIN_URL . 'build/extensions.js',
133 $asset_file['dependencies'],
134 $asset_file['version'],
135 true
136 );
137
138 // Set up script translations for i18n
139 wp_set_script_translations( 'wpgraphql-extensions', 'wp-graphql', WPGRAPHQL_PLUGIN_DIR . 'languages' );
140
141 wp_localize_script(
142 'wpgraphql-extensions',
143 'wpgraphqlExtensions',
144 [
145 'nonce' => wp_create_nonce( 'wp_rest' ),
146 'graphqlEndpoint' => trailingslashit( site_url() ) . 'index.php?' . graphql_get_endpoint(),
147 'extensions' => $this->get_extensions(),
148 'pluginsInstalled' => $this->get_installed_plugins(),
149 ]
150 );
151 }
152
153 /**
154 * Register custom REST API routes.
155 */
156 public function register_rest_routes(): void {
157 register_rest_route(
158 'wp/v2',
159 '/plugins/(?P<plugin>.+)',
160 [
161 'methods' => 'PUT',
162 'callback' => [ $this, 'activate_plugin' ],
163 'permission_callback' => static function () {
164 return current_user_can( 'activate_plugins' );
165 },
166 'args' => [
167 'plugin' => [
168 'required' => true,
169 'validate_callback' => static function ( $param, $request, $key ) {
170 return is_string( $param );
171 },
172 ],
173 ],
174 ]
175 );
176 }
177
178 /**
179 * Activate a plugin.
180 *
181 * @param \WP_REST_Request<array{plugin:string}> $request The REST request.
182 *
183 * @return \WP_REST_Response The REST response.
184 */
185 public function activate_plugin( WP_REST_Request $request ): WP_REST_Response {
186 $plugin = (string) $request->get_param( 'plugin' );
187 $result = activate_plugin( $plugin );
188
189 if ( is_wp_error( $result ) ) {
190 return new WP_REST_Response(
191 [
192 'status' => 'error',
193 'message' => $result->get_error_message(),
194 ],
195 500
196 );
197 }
198
199 return new WP_REST_Response(
200 [
201 'status' => 'active',
202 'plugin' => $plugin,
203 ],
204 200
205 );
206 }
207
208 /**
209 * Get the list of installed plugins
210 *
211 * @return array<string,array{
212 * is_active: bool,
213 * name: string,
214 * description: string,
215 * author: string,
216 * }> List of installed plugins, keyed by the plugin slug.
217 */
218 private function get_installed_plugins(): array {
219 if ( ! function_exists( 'get_plugins' ) ) {
220 require_once ABSPATH . 'wp-admin/includes/plugin.php';
221 }
222
223 $plugins = get_plugins();
224 $active_plugins = get_option( 'active_plugins' );
225 $installed_plugins = [];
226
227 foreach ( $plugins as $plugin_path => $plugin_info ) {
228 $slug = dirname( $plugin_path );
229
230 $installed_plugins[ $slug ] = [
231 'is_active' => in_array( $plugin_path, $active_plugins, true ),
232 'name' => $plugin_info['Name'],
233 'description' => $plugin_info['Description'],
234 'author' => $plugin_info['Author'],
235 ];
236 }
237
238 return $installed_plugins;
239 }
240
241 /**
242 * Sanitizes extension values before they are used.
243 *
244 * @param array<string,mixed> $extension The extension to sanitize.
245 * @return array{
246 * name: string|null,
247 * description: string|null,
248 * plugin_url: string|null,
249 * support_url: string|null,
250 * documentation_url: string|null,
251 * repo_url: string|null,
252 * author: array{
253 * name: string|null,
254 * homepage: string|null,
255 * },
256 * }
257 */
258 private function sanitize_extension( array $extension ): array {
259 return [
260 'name' => ! empty( $extension['name'] ) ? sanitize_text_field( $extension['name'] ) : null,
261 'description' => ! empty( $extension['description'] ) ? sanitize_text_field( $extension['description'] ) : null,
262 'plugin_url' => ! empty( $extension['plugin_url'] ) ? esc_url_raw( $extension['plugin_url'] ) : null,
263 'support_url' => ! empty( $extension['support_url'] ) ? esc_url_raw( $extension['support_url'] ) : null,
264 'documentation_url' => ! empty( $extension['documentation_url'] ) ? esc_url_raw( $extension['documentation_url'] ) : null,
265 'repo_url' => ! empty( $extension['repo_url'] ) ? esc_url_raw( $extension['repo_url'] ) : null,
266 'author' => [
267 'name' => ! empty( $extension['author']['name'] ) ? sanitize_text_field( $extension['author']['name'] ) : null,
268 'homepage' => ! empty( $extension['author']['homepage'] ) ? esc_url_raw( $extension['author']['homepage'] ) : null,
269 ],
270 ];
271 }
272
273 /**
274 * Validate an extension.
275 *
276 * Sanitization ensures that the values are correctly types, so we just need to check if the required fields are present.
277 *
278 * @param array<string,mixed> $extension The extension to validate.
279 *
280 * @return true|\WP_Error True if the extension is valid, otherwise an error.
281 *
282 * @phpstan-assert-if-true Extension $extension
283 */
284 public function is_valid_extension( array $extension ) {
285 $error_code = 'invalid_extension';
286 // translators: First placeholder is the extension name. Second placeholder is the property that is missing from the extension.
287 $error_message = __( 'Invalid extension %1$s is missing a valid value for %2$s.', 'wp-graphql' );
288
289 // First handle the name field, since we'll use it in other error messages.
290 if ( empty( $extension['name'] ) ) {
291 return new \WP_Error( $error_code, esc_html__( 'Invalid extension. All extensions must have a `name`.', 'wp-graphql' ) );
292 }
293
294 // Handle the Top-Level fields.
295 $required_fields = [
296 'description',
297 'plugin_url',
298 'support_url',
299 'documentation_url',
300 ];
301 foreach ( $required_fields as $property ) {
302 if ( empty( $extension[ $property ] ) ) {
303 return new \WP_Error(
304 $error_code,
305 sprintf( $error_message, $extension['name'], $property )
306 );
307 }
308 }
309
310 // Ensure Author has the required name field.
311 if ( empty( $extension['author']['name'] ) ) {
312 return new \WP_Error(
313 $error_code,
314 sprintf( $error_message, $extension['name'], 'author.name' )
315 );
316 }
317
318 return true;
319 }
320
321 /**
322 * Populate the extensions list with installation data.
323 *
324 * @param Extension[] $extensions The extensions to populate.
325 *
326 * @return PopulatedExtension[] The populated extensions.
327 */
328 private function populate_installation_data( $extensions ): array {
329 $installed_plugins = $this->get_installed_plugins();
330
331 $populated_extensions = [];
332
333 foreach ( $extensions as $extension ) {
334 $slug = basename( rtrim( $extension['plugin_url'], '/' ) );
335 $extension['installed'] = false;
336 $extension['active'] = false;
337
338 // If the plugin is installed, populate the installation data.
339 if ( isset( $installed_plugins[ $slug ] ) ) {
340 $extension['installed'] = true;
341 $extension['active'] = $installed_plugins[ $slug ]['is_active'];
342
343 if ( ! empty( $installed_plugins[ $slug ]['author'] ) ) {
344 $extension['author']['name'] = $installed_plugins[ $slug ]['author'];
345 }
346 }
347
348 // @todo Where does this come from?
349 if ( isset( $extension['settings_path'] ) && true === $extension['active'] ) {
350 $extension['settings_url'] = is_multisite() && is_network_admin()
351 ? network_admin_url( $extension['settings_path'] )
352 : admin_url( $extension['settings_path'] );
353 }
354
355 $populated_extensions[] = $extension;
356 }
357
358 /**
359 * Sort the extensions by the following criteria:
360 * 1. Plugins grouped by WordPress.org plugins first, non WordPress.org plugins after
361 * 2. Sort by plugin name in alphabetical order within the above groups, prioritizing "WPGraphQL" authored plugins
362 */
363 usort(
364 $populated_extensions,
365 static function ( $a, $b ) {
366 if ( false !== strpos( $a['plugin_url'], 'wordpress.org' ) && false === strpos( $b['plugin_url'], 'wordpress.org' ) ) {
367 return -1;
368 }
369 if ( false === strpos( $a['plugin_url'], 'wordpress.org' ) && false !== strpos( $b['plugin_url'], 'wordpress.org' ) ) {
370 return 1;
371 }
372 if ( ! empty( $a['author']['name'] ) && ( 'WPGraphQL' === $a['author']['name'] && ( ! empty( $b['author']['name'] ) && 'WPGraphQL' !== $b['author']['name'] ) ) ) {
373 return -1;
374 }
375 if ( ! empty( $a['author']['name'] ) && 'WPGraphQL' !== $a['author']['name'] && ( ! empty( $b['author']['name'] ) && 'WPGraphQL' === $b['author']['name'] ) ) {
376 return 1;
377 }
378 return strcasecmp( $a['name'], $b['name'] );
379 }
380 );
381
382 return $populated_extensions;
383 }
384
385 /**
386 * Get the list of WPGraphQL extensions.
387 *
388 * @return PopulatedExtension[] The list of extensions.
389 */
390 public function get_extensions(): array {
391 if ( ! isset( $this->extensions ) ) {
392 // @todo Replace with a call to the WPGraphQL server.
393 $extensions = Registry::get_extensions();
394
395 /**
396 * Filter the list of extensions, allowing other plugins to add or remove extensions.
397 *
398 * @see Admin\Extensions\Registry::get_extensions() for the correct format of the extensions.
399 *
400 * @param array<string,Extension> $extensions The list of extensions.
401 *
402 * @hookGroup settings
403 * @since 1.31.0
404 */
405 $extensions = apply_filters( 'graphql_get_extensions', $extensions );
406
407 $valid_extensions = [];
408 foreach ( $extensions as $extension ) {
409 $sanitized = $this->sanitize_extension( $extension );
410
411 if ( true === $this->is_valid_extension( $sanitized ) ) {
412 $valid_extensions[] = $sanitized;
413 }
414 }
415
416 // If we have valid extensions, populate the installation data.
417 if ( ! empty( $valid_extensions ) ) {
418 $valid_extensions = $this->populate_installation_data( $valid_extensions );
419 }
420
421 $this->extensions = $valid_extensions;
422 }
423
424 return $this->extensions;
425 }
426 }
427