PluginProbe
WPGraphQL IDE / 5.0.1
WPGraphQL IDE v5.0.1
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.0.1, at wpgraphql-ide.php

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