wpgraphql-ide.php
| 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.4.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.4.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 |