REDROOM
PHP 8.2.31
Path:
Logout
Edit File
Size: 21.50 KB
Close
/home/nshryvcy/radiantskinclinics.org/wp-content/plugins/woocommerce/src/Internal/Api/GraphQLController.php
Text
Base64
<?php declare(strict_types=1); namespace Automattic\WooCommerce\Internal\Api; use Automattic\WooCommerce\Api\ApiException; use Automattic\WooCommerce\Vendor\GraphQL\GraphQL; use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode; use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FieldNode; use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\InlineFragmentNode; use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\OperationDefinitionNode; use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode; use Automattic\WooCommerce\Vendor\GraphQL\Type\Schema; use Automattic\WooCommerce\Vendor\GraphQL\Error\DebugFlag; use Automattic\WooCommerce\Vendor\GraphQL\Validator\DocumentValidator; use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\DisableIntrospection; use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryComplexity; use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryDepth; /** * Handles incoming GraphQL requests over the WooCommerce REST API. */ abstract class GraphQLController { /** * Maximum nesting depth allowed in a GraphQL query. * * Queries exceeding this depth are rejected during validation, before any * resolver runs. See {@see self::get_max_query_depth()} for the accessor. */ private const MAX_QUERY_DEPTH = 15; /** * Maximum computed complexity score allowed for a GraphQL query. * * Complexity is the sum of per-field scores; connection fields multiply * their child score by the requested page size. Queries exceeding this * score are rejected during validation. See {@see self::get_max_query_complexity()}. */ private const MAX_QUERY_COMPLEXITY = 1000; /** * Cached GraphQL schema instance. * * @var ?Schema */ private ?Schema $schema = null; /** * Query cache / APQ resolver. * * @var QueryCache */ private QueryCache $query_cache; /** * DI: injected by WooCommerce container. * * @internal * @param QueryCache $query_cache The query cache instance. */ final public function init( QueryCache $query_cache ): void { $this->query_cache = $query_cache; } /** * The maximum nesting depth allowed in a GraphQL query. * * Exposed as a method so the limit can become configurable — e.g. via a * filter or store option — without requiring call-site changes. */ public static function get_max_query_depth(): int { return self::MAX_QUERY_DEPTH; } /** * The maximum computed complexity score allowed for a GraphQL query. * * Exposed as a method so the limit can become configurable — e.g. via a * filter or store option — without requiring call-site changes. */ public static function get_max_query_complexity(): int { return self::MAX_QUERY_COMPLEXITY; } /** * Register the GraphQL REST route. */ public function register(): void { $methods = Main::filter_methods_against_settings( array( 'GET', 'POST' ) ); if ( empty( $methods ) ) { return; } register_rest_route( 'wc', '/graphql', array( 'methods' => $methods, 'callback' => array( $this, 'handle_request' ), // Auth is handled per-query/mutation. 'permission_callback' => '__return_true', ) ); } /** * Handle an incoming GraphQL request. * * @param \WP_REST_Request $request The REST request. */ public function handle_request( \WP_REST_Request $request ): \WP_REST_Response { try { return $this->process_request( $request ); } catch ( \Throwable $e ) { $output = array( 'errors' => array( $this->format_exception( $e, $request ), ), ); $status = $this->get_error_status( $output['errors'] ); return new \WP_REST_Response( $output, $status ); } } /** * Process the GraphQL request. Extracted so that handle_request() can * wrap everything in a single try/catch that respects debug mode. * * @param \WP_REST_Request $request The REST request. */ private function process_request( \WP_REST_Request $request ): \WP_REST_Response { // 2. Parse request. GET query-string `variables` and `extensions` // arrive as JSON strings; decode_json_param() unifies them with the // already-decoded-array path from POST bodies and rejects malformed // or non-object payloads up front so they surface as HTTP 400 // INVALID_ARGUMENT instead of as confusing resolver errors (null // decode) or HTTP 500 TypeErrors (scalar decode). $query = $request->get_param( 'query' ); $operation_name = $request->get_param( 'operationName' ); $variables = $this->decode_json_param( $request->get_param( 'variables' ), 'variables' ); $extensions = $this->decode_json_param( $request->get_param( 'extensions' ), 'extensions' ); // 3. Resolve query (cache lookup / APQ / parse). $source = $this->query_cache->resolve( $query, $extensions ); if ( is_array( $source ) ) { return new \WP_REST_Response( $source, $this->get_resolve_error_status( $source ) ); } // 4. Reject mutations over GET (GraphQL over HTTP spec). if ( 'GET' === $request->get_method() && $this->document_has_mutation( $source, $operation_name ) ) { return new \WP_REST_Response( array( 'errors' => array( array( 'message' => 'Mutations are not allowed over GET requests. Use POST instead.', 'extensions' => array( 'code' => 'METHOD_NOT_ALLOWED' ), ), ), ), 405 ); } // 5. Load schema. $schema = $this->get_schema(); // 6. Build validation rules. // A single QueryComplexity instance is kept so its computed score can // be surfaced in the debug extensions after execution. $complexity_rule = new QueryComplexity( self::get_max_query_complexity() ); $validation_rules = array_values( DocumentValidator::allRules() ); $validation_rules[] = new QueryDepth( self::get_max_query_depth() ); $validation_rules[] = $complexity_rule; if ( ! $this->is_introspection_allowed( $request ) ) { $validation_rules[] = new DisableIntrospection( DisableIntrospection::ENABLED ); } // 7. Execute. $result = GraphQL::executeQuery( schema: $schema, source: $source, variableValues: $variables, operationName: $operation_name, validationRules: $validation_rules, ); // Install an error formatter that guarantees every error carries an // `extensions.code`. Our resolvers route everything through // Utils::execute_command / Utils::authorize_command, which already // translate domain exceptions (ApiException, InvalidArgumentException, // generic Throwable) into coded GraphQL errors at the throw site. // What reaches us uncoded here is webonyx-native validation and // execution output, so we infer from webonyx's ClientAware signal: // client-safe errors become BAD_USER_INPUT (400), the rest become // INTERNAL_ERROR (500). // // In debug mode the same formatter also walks the previous-exception // chain so wrapped errors (e.g. a \ValueError caught by a resolver and // re-thrown as INTERNAL_ERROR) stay visible to the developer instead // of being masked behind the generic "Internal server error" message. $debug_mode = $this->is_debug_mode( $request ); $result->setErrorFormatter( function ( \Throwable $error ) use ( $debug_mode ): array { $formatted = \Automattic\WooCommerce\Vendor\GraphQL\Error\FormattedError::createFromException( $error ); if ( ! isset( $formatted['extensions']['code'] ) ) { $client_safe = $error instanceof \Automattic\WooCommerce\Vendor\GraphQL\Error\ClientAware && $error->isClientSafe(); $formatted['extensions']['code'] = $client_safe ? 'BAD_USER_INPUT' : 'INTERNAL_ERROR'; } // SerializationError (thrown during schema-type coercion, e.g. when // a resolver returns an Int that doesn't fit 32 bits) extends // \Exception rather than webonyx's ClientAware Error, so it lands // in the INTERNAL_ERROR bucket above. Its message is actually // client-actionable ("value out of range — send smaller inputs"), // so promote it to BAD_USER_INPUT when it shows up anywhere in // the previous-exception chain. if ( 'BAD_USER_INPUT' !== ( $formatted['extensions']['code'] ?? null ) ) { $cursor = $error; while ( $cursor instanceof \Throwable ) { if ( $cursor instanceof \Automattic\WooCommerce\Vendor\GraphQL\Error\SerializationError ) { $formatted['extensions']['code'] = 'BAD_USER_INPUT'; break; } $cursor = $cursor->getPrevious(); } } if ( $debug_mode ) { $chain = $this->extract_previous_chain( $error ); if ( ! empty( $chain ) ) { $formatted['extensions']['previous'] = $chain; } } return $formatted; } ); $debug_flags = $this->get_debug_flags( $request ); $output = $result->toArray( $debug_flags ); // 8. Debug-mode metrics: expose the computed complexity and depth so // clients tuning queries can see what the server scored the request at. if ( $this->is_debug_mode( $request ) ) { if ( ! isset( $output['extensions'] ) ) { $output['extensions'] = array(); } if ( ! isset( $output['extensions']['debug'] ) ) { $output['extensions']['debug'] = array(); } $output['extensions']['debug']['complexity'] = $complexity_rule->getQueryComplexity(); $output['extensions']['debug']['depth'] = $this->compute_query_depth( $source, $operation_name ); } // 9. Determine HTTP status code. GraphQL emits `data: { field: null }` // for nullable root fields even when the resolver errored, so gating // the status override on `data` being absent would leave nearly every // error response on HTTP 200. Always derive the status from the // errors array when one is present — clients that need "200 with // partial data" semantics can still read the `errors` array. $status = isset( $output['errors'] ) ? $this->get_error_status( $output['errors'] ) : 200; return new \WP_REST_Response( $output, $status ); } /** * Build and cache the GraphQL schema. */ private function get_schema(): Schema { if ( null === $this->schema ) { $this->schema = $this->build_schema(); } return $this->schema; } /** * Construct the GraphQL schema. * * Implemented by the autogenerated subclass emitted by ApiBuilder * (both for WooCommerce core and for sibling plugins that reuse this * infrastructure) so the base class stays agnostic to any specific * autogenerated namespace. */ abstract protected function build_schema(): Schema; /** * Decode an optional JSON-object param (`variables` / `extensions`) into an array. * * WP_REST_Request delivers POST-body params as already-decoded arrays, * but GET query-string equivalents arrive as raw JSON strings. This * helper unifies the two and rejects malformed JSON or non-object * payloads with an InvalidArgumentException — which handle_request() * surfaces as HTTP 400 INVALID_ARGUMENT, rather than letting a null * decode slip through as "no variables" or a scalar decode trigger a * downstream TypeError / HTTP 500. * * @param mixed $value The param value from WP_REST_Request::get_param(). * @param string $name The param name, used in error messages. * @return array The decoded object, or an empty array when the param is omitted / empty / JSON null. * @throws \InvalidArgumentException When the payload is not a JSON object or not valid JSON. */ private function decode_json_param( $value, string $name ): array { if ( null === $value ) { return array(); } if ( is_array( $value ) ) { return $value; } // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON. if ( ! is_string( $value ) ) { throw new \InvalidArgumentException( sprintf( 'Argument `%s` must be a JSON object or omitted.', $name ) ); } if ( '' === $value ) { return array(); } $decoded = json_decode( $value, true ); if ( JSON_ERROR_NONE !== json_last_error() ) { throw new \InvalidArgumentException( sprintf( 'Argument `%s` is not valid JSON: %s', $name, json_last_error_msg() ) ); } if ( null === $decoded ) { // Literal "null" JSON payload — treat as omitted. return array(); } if ( ! is_array( $decoded ) ) { throw new \InvalidArgumentException( sprintf( 'Argument `%s` must be a JSON object (got %s).', $name, gettype( $decoded ) ) ); } return $decoded; // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } /** * Determine debug flags based on WP_DEBUG, user role, and query string. * * @param \WP_REST_Request $request The REST request. */ private function get_debug_flags( \WP_REST_Request $request ): int { if ( ! $this->is_debug_mode( $request ) ) { return DebugFlag::NONE; } return DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE; } /** * Check whether GraphQL introspection is allowed for this request. * * Introspection is permitted if either condition holds: * - The request is in debug mode ({@see self::is_debug_mode()}). * - The caller has the `manage_woocommerce` capability. * * Gating on capability rather than mere authentication keeps the full * schema (including admin-only mutations) hidden from low-privilege * roles such as `customer`, which every storefront account is assigned * at checkout — while still allowing admin tooling (e.g. GraphiQL-like * explorers) to query it. * * @param \WP_REST_Request $request The REST request. */ private function is_introspection_allowed( \WP_REST_Request $request ): bool { return $this->is_debug_mode( $request ) || current_user_can( 'manage_woocommerce' ); } /** * Check if debug mode is active. * * Debug mode is active when either: * - WP_DEBUG is enabled AND the current user is an administrator (or in a local environment). * - The current user is an administrator (or in a local environment) AND `_debug=1` is in the query string. * * @param \WP_REST_Request $request The REST request. */ private function is_debug_mode( \WP_REST_Request $request ): bool { if ( ! $this->is_local_environment() && ! current_user_can( 'manage_options' ) ) { return false; } if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { return true; } return '1' === $request->get_param( '_debug' ); } /** * Format a caught exception into a GraphQL error array. * * @param \Throwable $e The caught exception. * @param \WP_REST_Request $request The REST request. */ private function format_exception( \Throwable $e, \WP_REST_Request $request ): array { if ( $e instanceof ApiException ) { // Caller-supplied extensions come first so the canonical // getErrorCode() can't be silently overridden by an extensions // entry keyed 'code'. Mirrors the same invariant enforced by // Utils::translate_exceptions() for the execute/authorize paths. $error = array( 'message' => $e->getMessage(), 'extensions' => array_merge( $e->getExtensions(), array( 'code' => $e->getErrorCode() ) ), ); } elseif ( $e instanceof \InvalidArgumentException ) { $error = array( 'message' => $e->getMessage(), 'extensions' => array( 'code' => 'INVALID_ARGUMENT' ), ); } else { $error = array( 'message' => 'An unexpected error occurred.', 'extensions' => array( 'code' => 'INTERNAL_ERROR' ), ); } if ( $this->is_debug_mode( $request ) ) { $error['extensions']['debug'] = array( 'message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString(), ); $chain = $this->extract_previous_chain( $e ); if ( ! empty( $chain ) ) { $error['extensions']['debug']['previous'] = $chain; } } return $error; } /** * Walk the `getPrevious()` chain of a Throwable and return one entry per * wrapped exception. Used in debug mode so that resolver-level wrappers * (which bury the real cause behind a generic "INTERNAL_ERROR") still * surface the underlying class/message/file/line/trace. * * @param \Throwable $e The outermost exception. * @return array<int, array{class: string, message: string, file: string, line: int, trace: string[]}> */ private function extract_previous_chain( \Throwable $e ): array { $chain = array(); for ( $prev = $e->getPrevious(); null !== $prev; $prev = $prev->getPrevious() ) { $chain[] = array( 'class' => get_class( $prev ), 'message' => $prev->getMessage(), 'file' => $prev->getFile(), 'line' => $prev->getLine(), 'trace' => explode( "\n", $prev->getTraceAsString() ), ); } return $chain; } /** * Mapping from machine-readable error codes to HTTP status codes. * * Any code not listed here defaults to 500, so unknown/unrecognised codes * from third-party resolvers stay on the safe side. The error formatter * installed in process_request() guarantees every error carries a code * from this table before get_error_status() inspects it. */ private const ERROR_STATUS_MAP = array( 'UNAUTHORIZED' => 401, 'FORBIDDEN' => 403, 'NOT_FOUND' => 404, 'METHOD_NOT_ALLOWED' => 405, 'INVALID_ARGUMENT' => 400, 'BAD_USER_INPUT' => 400, 'GRAPHQL_PARSE_ERROR' => 400, 'GRAPHQL_PARSE_FAILED' => 400, 'GRAPHQL_VALIDATION_FAILED' => 400, 'VALIDATION_ERROR' => 422, 'INTERNAL_ERROR' => 500, ); /** * Determine the HTTP status code from an array of GraphQL errors. * * Applies the code-to-status lookup to each error and returns the worst * (highest) status seen. A single genuine 5xx among mixed errors surfaces * as 500, which is the more useful signal for monitoring and logs. * * @param array $errors The GraphQL errors array. */ private function get_error_status( array $errors ): int { $status = 200; foreach ( $errors as $error ) { $code = $error['extensions']['code'] ?? null; $mapped = self::ERROR_STATUS_MAP[ $code ] ?? 500; if ( $mapped > $status ) { $status = $mapped; } } return $status; } /** * Determine the HTTP status code for an error returned by QueryCache::resolve(). * * PERSISTED_QUERY_NOT_FOUND uses 200 per the Apollo APQ convention (protocol signal, not error). * * @param array $response The error response array from resolve(). */ private function get_resolve_error_status( array $response ): int { $code = $response['errors'][0]['extensions']['code'] ?? ''; if ( 'PERSISTED_QUERY_NOT_FOUND' === $code ) { return 200; } return 400; } /** * Compute the maximum nesting depth of the executing operation. * * Field selections add one level; inline fragments do not. Named-fragment * spreads are not expanded here — the depth returned is therefore a lower * bound when spreads are present. The webonyx QueryDepth validation rule * (which does expand spreads) remains the authoritative gate; this helper * only produces the metric surfaced in the debug extensions. * * @param DocumentNode $document The parsed GraphQL document. * @param ?string $operation_name The requested operation name, if any. */ private function compute_query_depth( DocumentNode $document, ?string $operation_name ): int { $max = 0; foreach ( $document->definitions as $definition ) { if ( ! $definition instanceof OperationDefinitionNode ) { continue; } if ( null !== $operation_name && ( $definition->name->value ?? null ) !== $operation_name ) { continue; } $max = max( $max, $this->walk_depth( $definition->selectionSet, 0 ) ); } return $max; } /** * Recursively walk a selection set and return the maximum depth reached. * * @param ?SelectionSetNode $selection_set The selection set to walk, or null for a leaf. * @param int $depth The depth of the selection set's parent. */ private function walk_depth( ?SelectionSetNode $selection_set, int $depth ): int { if ( null === $selection_set ) { return $depth; } $max = $depth; foreach ( $selection_set->selections as $selection ) { if ( $selection instanceof FieldNode ) { $max = max( $max, $this->walk_depth( $selection->selectionSet, $depth + 1 ) ); } elseif ( $selection instanceof InlineFragmentNode ) { $max = max( $max, $this->walk_depth( $selection->selectionSet, $depth ) ); } } return $max; } /** * Check whether the parsed document contains a mutation operation. * * When an operation name is given, only that operation is checked; * otherwise any mutation definition in the document triggers a match. * * @param DocumentNode $document The parsed GraphQL document. * @param ?string $operation_name The requested operation name, if any. */ private function document_has_mutation( DocumentNode $document, ?string $operation_name ): bool { foreach ( $document->definitions as $definition ) { if ( ! $definition instanceof OperationDefinitionNode ) { continue; } if ( null !== $operation_name && ( $definition->name->value ?? null ) !== $operation_name ) { continue; } if ( 'mutation' === $definition->operation ) { return true; } } return false; } /** * Check if running in a local/development environment. * * Prefers {@see wp_get_environment_type()} when available. Otherwise * parses the site URL and performs a case-insensitive *exact* match * against the hostname — not a substring check, to avoid matching * impostor domains like `mylocalhost.com` or `127.0.0.1.attacker.example`. */ private function is_local_environment(): bool { if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) { return true; } $host = wp_parse_url( get_site_url(), PHP_URL_HOST ); if ( ! is_string( $host ) ) { return false; } $host = strtolower( $host ); return 'localhost' === $host || '127.0.0.1' === $host; } }
Save
Close
Exit & Reset
Text mode: syntax highlighting auto-detects file type.
Directory Contents
Dirs: 2 × Files: 7
Delete Selected
Select All
Select None
Sort:
Name
Size
Modified
Enable drag-to-move
Name
Size
Perms
Modified
Actions
Autogenerated
DIR
-
drwxr-xr-x
2026-05-29 02:43:21
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
Schema
DIR
-
drwxr-xr-x
2026-05-29 02:43:21
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
GraphQLController.php
21.50 KB
lrw-r--r--
2026-05-05 14:26:50
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
GraphQLEndpointRegistrar.php
2.09 KB
lrw-r--r--
2026-05-05 14:26:50
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
Main.php
12.55 KB
lrw-r--r--
2026-05-05 14:26:50
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
QueryCache.php
4.68 KB
lrw-r--r--
2026-05-05 14:26:50
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
QueryInfoExtractor.php
7.14 KB
lrw-r--r--
2026-05-05 14:26:50
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
Settings.php
2.06 KB
lrw-r--r--
2026-05-05 14:26:50
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
Utils.php
7.68 KB
lrw-r--r--
2026-05-05 14:26:50
Edit
Download
Rename
Chmod
Change Date
Delete
OK
Cancel
recursive
OK
Cancel
recursive
OK
Cancel
Zip Selected
If ZipArchive is unavailable, a
.tar
will be created (no compression).