PluginProbe
WPGraphQL IDE / 5.1.0
WPGraphQL IDE v5.1.0
5.5.0 5.4.2 5.4.1 5.4.0 5.3.0 5.1.0 5.2.0 5.0.1 5.0.0 4.5.0 4.4.1 trunk 4.0.3 4.1.0 4.2.0 4.3.0 4.4.0
wpgraphql-ide / wpgraphql-ide.php

wpgraphql-ide.php in WPGraphQL IDE 5.1.0, at wpgraphql-ide.php

368 lines 13.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Name: WPGraphQL IDE
4 * Plugin URI: https://wordpress.org/plugins/wpgraphql-ide/
5 * Description: A next-gen query editor for WPGraphQL.
6 * Author: WPGraphQL
7 * Author URI: https://www.wpgraphql.com
8 * GitHub Plugin URI: https://github.com/wp-graphql/wp-graphql
9 * License: GPL-3
10 * License URI: https://www.gnu.org/licenses/gpl-3.0.html
11 * Text Domain: wpgraphql-ide
12 * Domain Path: /languages
13 * Version: 5.1.0
14 * Requires at least: 6.1
15 * Tested up to: 7.0
16 * Requires PHP: 7.4
17 * Requires Plugins: wp-graphql
18 *
19 * @package WPGraphQLIDE
20 */
21
22 namespace WPGraphQLIDE;
23
24 if ( ! defined( 'ABSPATH' ) ) {
25 exit;
26 }
27
28 if ( file_exists( __DIR__ . '/vendor/autoload.php' ) ) {
29 require_once __DIR__ . '/vendor/autoload.php';
30 }
31
32 define( 'WPGRAPHQL_IDE_VERSION', '5.1.0' );
33 define( 'WPGRAPHQL_IDE_ROOT_ELEMENT_ID', 'wpgraphql-ide-root' );
34 define( 'WPGRAPHQL_IDE_PLUGIN_DIR_PATH', plugin_dir_path( __FILE__ ) );
35 define( 'WPGRAPHQL_IDE_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
36 define( 'WPGRAPHQL_IDE_PLUGIN_FILE', __FILE__ );
37
38 /**
39 * Manual PSR-4 autoloader for the `WPGraphQLIDE\` namespace.
40 *
41 * Composer's autoloader does the same thing once `composer install` has
42 * been run — but cross-plugin CI jobs only run composer install in their
43 * own plugin's directory, so when wp-env loads the IDE alongside (e.g.)
44 * smart-cache integration tests, the IDE's `vendor/` doesn't exist and
45 * every classed-out `\WPGraphQLIDE\Foo::method()` call fatals before
46 * the request can render. Same risk for Bedrock-style installs that
47 * skip per-plugin composer.
48 *
49 * Registering this fallback is harmless when Composer's autoloader has
50 * already loaded — the SPL chain just falls through to it for any
51 * non-IDE class.
52 */
53 spl_autoload_register(
54 static function ( $class ) {
55 $prefix = 'WPGraphQLIDE\\';
56 $len = strlen( $prefix );
57 if ( strncmp( $prefix, $class, $len ) !== 0 ) {
58 return;
59 }
60 $relative = substr( $class, $len );
61 $file = WPGRAPHQL_IDE_PLUGIN_DIR_PATH . 'includes/' . str_replace( '\\', '/', $relative ) . '.php';
62 if ( file_exists( $file ) ) {
63 // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable -- Path is constrained by the prefix check + file_exists guard above; mapping a class name to its PSR-4 file is intrinsically variable.
64 require_once $file;
65 }
66 }
67 );
68
69 // Modular feature includes — kept out of this main plugin file to avoid
70 // further bloat. Each include hooks into WordPress on its own.
71 require_once __DIR__ . '/includes/access-functions.php';
72 require_once __DIR__ . '/includes/settings.php';
73 require_once __DIR__ . '/includes/document-settings.php';
74 require_once __DIR__ . '/includes/public-endpoint.php';
75
76 /**
77 * Check if WPGraphQL is available and handle the case where it is not.
78 *
79 * @return void
80 */
81 function check_wpgraphql_availability() {
82 // Check for the WPGraphQL class (available on init)
83 // Router is initialized later on after_setup_theme, but we check for it in the enqueue function
84 if ( ! class_exists( 'WPGraphQL' ) ) {
85 add_action( 'admin_notices', __NAMESPACE__ . '\\show_admin_notice' );
86 } else {
87 add_custom_capabilities();
88
89 do_action( 'wpgraphql_ide_init' );
90 }
91 }
92 add_action( 'plugins_loaded', __NAMESPACE__ . '\\check_wpgraphql_availability' );
93
94 /**
95 * Initialize the plugin.
96 *
97 * @return void
98 */
99 function initialize_plugin() {
100 // Translation loading is handled by WordPress automatically since
101 // 4.6+ for plugins with a matching `Text Domain:` header (we have
102 // it, line 10). Calling `load_plugin_textdomain` ourselves used to
103 // be the convention but is now redundant — and on WP 6.7+ it
104 // actively races with WordPress's own just-in-time loader, which
105 // fires `_doing_it_wrong` warnings whenever WP-CLI scans plugin
106 // metadata before `init`. Letting WP own the loading entirely
107 // removes our half of the race.
108 add_action( 'init', [ \WPGraphQLIDE\UserMeta::class, 'register' ] );
109
110 // Bridge Smart Cache's primitives (graphql_document + 4 taxonomies)
111 // into REST exposure the IDE's JS client needs. No-op without Smart
112 // Cache. Wire the filters now — before Smart Cache's `init` priority
113 // 10 fires register_post_type / register_taxonomy.
114 \WPGraphQLIDE\SmartCacheBridge::register();
115 add_action( 'admin_menu', [ \WPGraphQLIDE\AdminUI::class, 'register_dedicated_ide_menu' ] );
116 add_action( 'admin_bar_menu', [ \WPGraphQLIDE\AdminUI::class, 'register_wpadminbar_menus' ], 999 );
117 add_action( 'admin_enqueue_scripts', [ \WPGraphQLIDE\AdminUI::class, 'enqueue_graphql_ide_menu_icon_css' ] );
118 add_action( 'wp_enqueue_scripts', [ \WPGraphQLIDE\AdminUI::class, 'enqueue_graphql_ide_menu_icon_css' ] );
119 // Enqueue scripts on both admin and frontend since admin bar appears on both
120 add_action( 'admin_enqueue_scripts', [ \WPGraphQLIDE\AssetEnqueue::class, 'enqueue' ] );
121 add_action( 'wp_enqueue_scripts', [ \WPGraphQLIDE\AssetEnqueue::class, 'enqueue' ] );
122
123 add_action( 'graphql_register_settings', [ \WPGraphQLIDE\SettingsPage::class, 'register' ] );
124 add_action( 'graphql_admin_notices_render_notices', [ \WPGraphQLIDE\AdminUI::class, 'graphql_admin_notices_render_notices' ], 10, 1 );
125 add_action( 'graphql_admin_notices_render_notice', [ \WPGraphQLIDE\AdminUI::class, 'graphql_admin_notices_render_notice' ], 10, 4 );
126
127 add_filter( 'graphql_admin_notices_is_allowed_admin_page', [ \WPGraphQLIDE\AdminUI::class, 'graphql_admin_notices_is_allowed_admin_page' ], 10, 3 );
128 add_filter( 'script_loader_tag', [ \WPGraphQLIDE\AssetEnqueue::class, 'defer_script_attribute' ], 10, 2 );
129 add_filter( 'graphql_setting_field_config', [ \WPGraphQLIDE\SettingsPage::class, 'rewrite_legacy_graphiql_link' ], 10, 3 );
130 add_filter( 'graphql_get_setting_section_field_value', [ \WPGraphQLIDE\SettingsPage::class, 'force_legacy_graphiql_off' ], 10, 5 );
131 add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), [ \WPGraphQLIDE\AdminUI::class, 'add_settings_link' ] );
132 add_filter( 'plugin_row_meta', [ \WPGraphQLIDE\AdminUI::class, 'add_plugin_row_meta' ], 10, 2 );
133
134 // Scope REST queries to the current user's own documents.
135 // `graphql_document` is Smart Cache's saved-document post type — the
136 // filter no-ops when SC isn't installed (the hook simply never fires).
137 add_filter( 'rest_graphql_document_query', [ \WPGraphQLIDE\Access::class, 'scope_rest_queries' ] );
138
139 // Enforce manage_graphql_ide capability on all IDE REST routes.
140 add_filter( 'rest_pre_dispatch', [ \WPGraphQLIDE\Access::class, 'enforce_rest_permissions' ], 10, 3 );
141
142 // Prevent access to documents owned by other users on single routes.
143 add_filter( 'rest_prepare_graphql_document', [ \WPGraphQLIDE\Access::class, 'restrict_document_response' ], 10, 3 );
144
145 // Cap document title length on every write path so a long POST body
146 // can't bloat the DB or break admin-UI layouts. Covers REST creates
147 // and updates, the import/upsert flow, and any future direct
148 // `wp_insert_post` callers.
149 add_filter( 'wp_insert_post_data', [ \WPGraphQLIDE\Access::class, 'cap_document_title_length' ], 10, 2 );
150
151 // Custom REST routes.
152 add_action( 'rest_api_init', [ \WPGraphQLIDE\Rest::class, 'register' ] );
153
154 // GraphQL: scope Smart Cache `graphqlDocument` connections to the
155 // current user so the IDE's data is queryable from GraphQL but
156 // isolated per user — same contract as the REST endpoints. The
157 // `graphql_data_is_private` filter closes the single-node lookup
158 // hole left by the connection-only filter: without it,
159 // `node(id: "...")` could resolve another user's document if the
160 // requester knew its global ID.
161 add_filter( 'graphql_connection_query_args', [ \WPGraphQLIDE\Access::class, 'scope_graphql_connections' ], 10, 2 );
162 add_filter( 'graphql_data_is_private', [ \WPGraphQLIDE\Access::class, 'restrict_post_visibility' ], 10, 6 );
163
164 // Strip a deleted document's id from its owner's personal collections.
165 add_action( 'before_delete_post', [ \WPGraphQLIDE\UserMeta::class, 'purge_document_from_personal_collections' ], 10, 2 );
166
167 // Core plugins/modules.
168 require_once WPGRAPHQL_IDE_PLUGIN_DIR_PATH . 'plugins/query-composer-panel/query-composer-panel.php';
169 require_once WPGRAPHQL_IDE_PLUGIN_DIR_PATH . 'plugins/help-panel/help-panel.php';
170 require_once WPGRAPHQL_IDE_PLUGIN_DIR_PATH . 'plugins/smart-cache-panel/smart-cache-panel.php';
171 }
172 add_action( 'wpgraphql_ide_init', __NAMESPACE__ . '\\initialize_plugin' );
173
174 /**
175 * Show admin notice if WPGraphQL is not available.
176 *
177 * @return void
178 */
179 function show_admin_notice() {
180 ?>
181 <div class="notice notice-error">
182 <h3><?php esc_html_e( 'WPGraphQL IDE cannot load', 'wpgraphql-ide' ); ?></h3>
183 <ol>
184 <li><?php esc_html_e( 'WPGraphQL must be installed and active', 'wpgraphql-ide' ); ?></li>
185 </ol>
186 </div>
187 <?php
188 }
189
190 /**
191 * Assign custom capability to administrator role on plugin activation.
192 */
193 function wpgraphql_ide_activate(): void {
194 $administrator = get_role( 'administrator' );
195 if ( $administrator ) {
196 $administrator->add_cap( 'manage_graphql_ide' );
197 }
198 }
199 register_activation_hook( __FILE__, __NAMESPACE__ . '\\wpgraphql_ide_activate' );
200
201
202 /**
203 * Adds custom capabilities to specified roles.
204 *
205 * Runs on every `plugins_loaded` (not just activation) so the cap is granted
206 * for installs that never fire the activation hook — must-use plugins,
207 * Composer/bootstrap loads, or sites where the plugin is force-activated.
208 *
209 * The stored hash is only a fast-path to skip the role writes when nothing has
210 * changed. We deliberately do NOT trust it on its own: a role can lose the cap
211 * after the hash is saved (role reset/migration, multisite role sync, a manual
212 * edit), and a hash-only guard would then leave administrators permanently
213 * without `manage_graphql_ide`. So we also re-apply whenever a target role is
214 * actually missing its cap, which makes this self-healing and idempotent.
215 */
216 function add_custom_capabilities(): void {
217 $capabilities = get_custom_capabilities();
218 $current_hash = generate_capabilities_hash( $capabilities );
219
220 // Skip only when the definition is unchanged AND every role already holds
221 // its cap. Either condition failing means we (re)apply.
222 if ( ! has_capabilities_hash_changed( $current_hash ) && capabilities_are_applied( $capabilities ) ) {
223 return;
224 }
225
226 update_roles_capabilities( $capabilities );
227 save_capabilities_hash( $current_hash );
228 }
229
230 /**
231 * Whether every role already holds each of its declared capabilities.
232 *
233 * @since 5.0.1
234 *
235 * @param array<string,string[]> $capabilities Map of capability => role slugs.
236 * @return bool True only if all roles exist and already have their caps.
237 */
238 function capabilities_are_applied( array $capabilities ): bool {
239 foreach ( $capabilities as $capability => $roles ) {
240 foreach ( $roles as $role_name ) {
241 $role = get_role( $role_name );
242 if ( ! $role instanceof \WP_Role || ! $role->has_cap( $capability ) ) {
243 return false;
244 }
245 }
246 }
247
248 return true;
249 }
250
251 /**
252 * Retrieves the custom capabilities and their associated roles for the plugin.
253 *
254 * @return array<string,mixed> The array of custom capabilities and roles.
255 */
256 function get_custom_capabilities() {
257 return [
258 'manage_graphql_ide' => [ 'administrator' ],
259 ];
260 }
261
262 /**
263 * Generate a hash for the capabilities array.
264 *
265 * @param array<string,mixed> $capabilities Array of capabilities and roles.
266 * @return string MD5 hash of the capabilities array.
267 */
268 function generate_capabilities_hash( $capabilities ) {
269 return md5( (string) wp_json_encode( $capabilities ) );
270 }
271
272 /**
273 * Check if the capabilities hash has changed.
274 *
275 * @param string $current_hash Current hash of the capabilities array.
276 * @return bool True if the hash has changed, false otherwise.
277 */
278 function has_capabilities_hash_changed( $current_hash ) {
279 $stored_hash = get_option( 'wpgraphql_ide_capabilities' );
280 return $current_hash !== $stored_hash;
281 }
282
283 /**
284 * Update the capabilities for the specified roles.
285 *
286 * @param array<string,mixed> $capabilities Array of capabilities and roles.
287 */
288 function update_roles_capabilities( $capabilities ): void {
289 foreach ( $capabilities as $capability => $roles ) {
290 foreach ( $roles as $role_name ) {
291 $role = get_role( $role_name );
292
293 if ( $role && ! $role->has_cap( $capability ) ) {
294 $role->add_cap( $capability );
295 }
296 }
297 }
298 }
299
300 /**
301 * Save the new capabilities hash in the options table.
302 *
303 * @param string $current_hash Current hash of the capabilities array.
304 */
305 function save_capabilities_hash( $current_hash ): void {
306 update_option( 'wpgraphql_ide_capabilities', $current_hash );
307 }
308
309 /**
310 * Checks if the current user has the capability required to load scripts and styles for the GraphQL IDE.
311 *
312 * Back-compat wrapper around {@see wpgraphql_ide_user_can()} — the global-
313 * namespace helper is the single source of truth and is what new code
314 * should call directly.
315 *
316 * @return bool Whether the user has the required capability.
317 */
318 function user_has_graphql_ide_capability(): bool {
319 return wpgraphql_ide_user_can();
320 }
321
322 /**
323 * Determines if the current admin page is a dedicated WPGraphQL IDE page.
324 *
325 * @return bool True if the current page is a dedicated WPGraphQL IDE page, false otherwise.
326 */
327 function current_screen_is_dedicated_ide_page(): bool {
328 return is_ide_page() || is_legacy_ide_page();
329 }
330
331 /**
332 * Checks if the current admin page is the new WPGraphQL IDE page.
333 *
334 * @return bool True if the current page is the new WPGraphQL IDE page, false otherwise.
335 */
336 function is_ide_page(): bool {
337 if ( ! function_exists( 'get_current_screen' ) ) {
338 return false;
339 }
340
341 $screen = get_current_screen();
342 if ( ! ( $screen instanceof \WP_Screen ) ) {
343 return false;
344 }
345
346 return 'graphql_page_graphql-ide' === $screen->id;
347 }
348
349 /**
350 * Checks if the current admin page is the legacy GraphiQL IDE page.
351 *
352 * @return bool True if the current page is the legacy GraphiQL IDE page, false otherwise.
353 */
354 function is_legacy_ide_page(): bool {
355 if ( ! function_exists( 'get_current_screen' ) ) {
356 return false;
357 }
358
359 $screen = get_current_screen();
360 if ( ! ( $screen instanceof \WP_Screen ) ) {
361 return false;
362 }
363
364 return 'toplevel_page_graphiql-ide' === $screen->id;
365 }
366
367 \WPGraphQLIDE\Telemetry::init();
368