*/ public $fields; /** * Model constructor. * * @param string $restricted_cap The capability to check against to determine if * the data should be restricted or not * @param string[] $allowed_restricted_fields The allowed fields if the data is in fact restricted * @param int|null $owner Database ID of the user that owns this piece of * data to compare with the current user ID * * @return void * @throws \Exception Throws Exception. */ protected function __construct( $restricted_cap = '', $allowed_restricted_fields = [], $owner = null ) { if ( empty( $this->data ) ) { // translators: %s is the name of the model. throw new Exception( esc_html( sprintf( __( 'An empty data set was used to initialize the modeling of this %s object', 'wp-graphql' ), $this->get_model_name() ) ) ); } $this->restricted_cap = $restricted_cap; $this->allowed_restricted_fields = $allowed_restricted_fields; $this->owner = $owner; $this->current_user = wp_get_current_user(); if ( 'private' === $this->get_visibility() ) { return; } $this->init(); $this->prepare_fields(); } /** * Magic method to re-map the isset check on the child class looking for properties when * resolving the fields * * @param string $key The name of the field you are trying to retrieve * * @return bool */ public function __isset( $key ) { return isset( $this->fields[ $key ] ); } /** * Magic method to re-map setting new properties to the class inside of the $fields prop rather * than on the class in unique properties * * @param string $key Name of the key to set the data to * @param callable|int|string|mixed $value The value to set to the key * * @return void */ public function __set( $key, $value ) { $this->fields[ $key ] = $value; } /** * Magic method to re-map where external calls go to look for properties on the child objects. * This is crucial to let objects modeled through this class work with the default field * resolver. * * @param string $key Name of the property that is trying to be accessed * * @return mixed|null */ public function __get( $key ) { if ( ! array_key_exists( $key, $this->fields ) ) { return null; } // Unresolved fields are closures (see wrap_fields()); resolve and memoize them. // Anything else is already-resolved data, including strings that happen to // collide with a defined function name ("Max" vs max()), which is_callable() // would match case-insensitively and invoke the builtin instead of returning // the value. if ( $this->fields[ $key ] instanceof \Closure ) { $data = call_user_func( $this->fields[ $key ] ); $this->$key = $data; return $data; } return $this->fields[ $key ]; } /** * Setup the global state before each field is resolved so the Model has the necessary context. * * @return void */ public function setup() { } /** * Tear-down call that runs after each field is resolved. * * This can be used to reset state to where it was before the model was setup. * * @return void */ public function tear_down() { } /** * Returns the name of the model, built from the child className * * @return string */ protected function get_model_name() { if ( empty( $this->model_name ) ) { $name = static::class; if ( false !== strpos( static::class, '\\' ) ) { $starting_character = strrchr( static::class, '\\' ); if ( ! empty( $starting_character ) ) { $name = substr( $starting_character, 1 ); } } $this->model_name = $name . 'Object'; } return $this->model_name; } /** * Return the visibility state for the current piece of data * * @return string|null */ public function get_visibility() { if ( null === $this->visibility ) { /** * Filter for the capability to check against for restricted data * * @param string $restricted_cap The capability to check against * @param string $model_name Name of the model the filter is currently being executed in * @param TData $data The un-modeled incoming data * @param string|null $visibility The visibility that has currently been set for the data at this point * @param int|null $owner The user ID for the owner of this piece of data * @param \WP_User $current_user The current user for the session * * @hookGroup models * @since 0.3.0 * @return string */ $protected_cap = apply_filters( 'graphql_restricted_data_cap', $this->restricted_cap, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user ); /** * Filter to short circuit default is_private check for the model. This is expensive in some cases so * this filter lets you prevent this from running by returning a true or false value. * * @param ?bool $is_private Whether the model data is private. Defaults to null. * @param string $model_name Name of the model the filter is currently being executed in * @param TData $data The un-modeled incoming data * @param string|null $visibility The visibility that has currently been set for the data at this point * @param int|null $owner The user ID for the owner of this piece of data * @param \WP_User $current_user The current user for the session * * @hookGroup models * @since 1.1.3 * @return bool|null */ $pre_is_private = apply_filters( 'graphql_pre_model_data_is_private', null, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user ); // If 3rd party code has not filtered this, use the Models default logic to determine // whether the model should be considered private if ( null !== $pre_is_private ) { $is_private = $pre_is_private; } else { $is_private = $this->is_private(); } /** * Filter to determine if the data should be considered private or not * * @param bool $is_private Whether the model is private * @param string $model_name Name of the model the filter is currently being executed in * @param TData $data The un-modeled incoming data * @param string|null $visibility The visibility that has currently been set for the data at this point * @param int|null $owner The user ID for the owner of this piece of data * @param \WP_User $current_user The current user for the session * * @hookGroup models * @since 0.3.0 * @return bool */ $is_private = apply_filters( 'graphql_data_is_private', (bool) $is_private, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user ); if ( true === $is_private ) { $this->visibility = 'private'; } elseif ( null !== $this->owner && true === $this->owner_matches_current_user() ) { $this->visibility = 'public'; } elseif ( empty( $protected_cap ) || current_user_can( $protected_cap ) ) { $this->visibility = 'public'; } else { $this->visibility = 'restricted'; } } /** * Filter the visibility name to be returned * * @param string|null $visibility The visibility that has currently been set for the data at this point * @param string $model_name Name of the model the filter is currently being executed in * @param TData $data The un-modeled incoming data * @param int|null $owner The user ID for the owner of this piece of data * @param \WP_User $current_user The current user for the session * * @hookGroup models * @since 0.3.0 * @return string */ return apply_filters( 'graphql_object_visibility', $this->visibility, $this->get_model_name(), $this->data, $this->owner, $this->current_user ); } /** * Method to return the private state of the object. Can be overwritten in classes extending * this one. * * @return bool */ protected function is_private() { return false; } /** * Whether or not the owner of the data matches the current user * * @return bool */ protected function owner_matches_current_user() { if ( empty( $this->current_user->ID ) || empty( $this->owner ) ) { return false; } return absint( $this->owner ) === absint( $this->current_user->ID ); } /** * Restricts fields for the data to only return the allowed fields if the data is restricted * * @return void */ protected function restrict_fields() { $this->fields = array_intersect_key( $this->fields, array_flip( /** * Filter for the allowed restricted fields * * @param string[] $allowed_restricted_fields The fields to allow when the data is designated as restricted to the current user * @param string $model_name Name of the model the filter is currently being executed in * @param TData $data The un-modeled incoming data * @param string|null $visibility The visibility that has currently been set for the data at this point * @param int|null $owner The user ID for the owner of this piece of data * @param \WP_User $current_user The current user for the session */ apply_filters( 'graphql_allowed_fields_on_restricted_type', $this->allowed_restricted_fields, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user ) ) ); } /** * Wraps all fields with another callback layer so we can inject hooks & filters into them * * @return void */ protected function wrap_fields() { if ( ! is_array( $this->fields ) || empty( $this->fields ) ) { return; } $clean_array = []; foreach ( $this->fields as $key => $data ) { $clean_array[ $key ] = function () use ( $key, $data ) { /** * Filter to short circuit the callback for any field on a type. * * Returning anything other than null will stop the callback for the field from executing, * and will return your data or execute your callback instead. * * @param mixed $result The data returned from the callback. Null by default. * @param string $key The name of the field on the type * @param string $model_name Name of the model the filter is currently being executed in * @param TData $data The un-modeled incoming data * @param string $visibility The visibility setting for this piece of data * @param int|null $owner The user ID for the owner of this piece of data * @param \WP_User $current_user The current user for the session * @hookGroup models * @since 0.3.0 */ $pre = apply_filters( 'graphql_pre_return_field_from_model', null, $key, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user ); if ( ! is_null( $pre ) ) { // If the pre filter returns a value, we use that instead of the callback. $result = $pre; } else { $result = $this->prepare_field( $key, $data ); } /** * Hook that fires after the data is returned for the field * * @param mixed $result The returned data for the field * @param string $key The name of the field on the type * @param string $model_name Name of the model the filter is currently being executed in * @param TData $data The un-modeled incoming data * @param string $visibility The visibility setting for this piece of data * @param int|null $owner The user ID for the owner of this piece of data * @param \WP_User $current_user The current user for the session * @hookGroup models * @since 0.3.0 */ do_action( 'graphql_after_return_field_from_model', $result, $key, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user ); return $result; }; } $this->fields = $clean_array; } /** * Prepares an individual field for the model. * * @param string $field_name The name of the field on the type * @param TData $field The field data to prepare. * * @return TData */ private function prepare_field( string $field_name, $field ) { $can_access_field = $this->current_user_can_access_field( $field_name, $field ); // If the field is an array with a 'callback', use that as the callback. if ( is_array( $field ) && ! empty( $field['callback'] ) ) { $field = $field['callback']; } // If the user doesn't have access to the field, sanitize it to null. if ( ! $can_access_field ) { $field = null; } // Invoke Closures and callable arrays (the resolver shapes WPGraphQL installs), // but never a bare callable string. A field definition that is a string is data, // not a resolver we registered (for example a value that happens to match a PHP // function name), so it must be returned rather than invoked. This mirrors the // Closure gate in __get(). Defense in depth for GHSA-7922 / CVE-2026-18944. if ( is_callable( $field ) && ! is_string( $field ) ) { $this->setup(); $field = call_user_func( $field ); $this->tear_down(); } /** * Filter the data returned by the default callback for the field * * @param mixed $field The data returned from the callback * @param string $field_name The name of the field on the type * @param string $model_name Name of the model the filter is currently being executed in * @param TData $data The un-modeled incoming data * @param string $visibility The visibility setting for this piece of data * @param int|null $owner The user ID for the owner of this piece of data * @param \WP_User $current_user The current user for the session * @hookGroup models * @since 0.3.0 */ return apply_filters( 'graphql_return_field_from_model', $field, $field_name, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user ); } /** * Returns the capability to check for the field, or null if there is no capability set. * * @uses 'graphql_model_field_capability' to filter the capability to check for the field. * * @param string $field_name The name of the field to check * @param mixed $field The original metadata for the field. */ private function current_user_can_access_field( string $field_name, $field ): bool { $capability = ''; // If the field metadata is an array, check for the capability key if ( is_array( $field ) && isset( $field['capability'] ) ) { $capability = (string) $field['capability']; } /** * Capability to check required for the field * * @param string $capability The capability to check against to return the field * @param string $field_name The name of the field on the type * @param string $model_name Name of the model the filter is currently being executed in * @param TData $data The un-modeled incoming data * @param string $visibility The visibility setting for this piece of data * @param int|null $owner The user ID for the owner of this piece of data * @param \WP_User $current_user The current user for the session * @hookGroup models * @since 0.3.0 */ $capability = apply_filters( 'graphql_model_field_capability', $capability, $field_name, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user ); if ( empty( $capability ) ) { return true; } // @todo add support passing capability args. if ( current_user_can( $capability ) ) { return true; } // Surface the denial in debug mode so the resulting null is // discoverable rather than silent. graphql_debug( sprintf( // translators: 1: model + field name, 2: required WordPress capability. __( 'The "%1$s" field requires the "%2$s" capability and resolved to null.', 'wp-graphql' ), $this->get_model_name() . '.' . $field_name, $capability ), [ 'type' => 'RESTRICTED_FIELD', 'field' => $this->get_model_name() . '.' . $field_name, 'required_capability' => $capability, ] ); return false; } /** * Adds the model visibility fields to the data */ private function add_model_visibility(): void { /** * @todo: potentially abstract this out into a more central spot */ $this->fields['isPublic'] = function () { return 'public' === $this->get_visibility(); }; $this->fields['isRestricted'] = function () { return 'restricted' === $this->get_visibility(); }; $this->fields['isPrivate'] = function () { return 'private' === $this->get_visibility(); }; } /** * Returns instance of the data fully modeled * * @return void */ protected function prepare_fields() { if ( 'restricted' === $this->get_visibility() ) { $this->restrict_fields(); } /** * Filter the array of fields for the Model before the object is hydrated with it * * @param array $fields The array of fields for the model * @param string $model_name Name of the model the filter is currently being executed in * @param TData $data The un-modeled incoming data * @param string $visibility The visibility setting for this piece of data * @param ?int $owner The user ID for the owner of this piece of data * @param \WP_User $current_user The current user for the session * @hookGroup models * @since 1.7.0 */ $this->fields = apply_filters( 'graphql_model_prepare_fields', $this->fields, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user ); $this->wrap_fields(); $this->add_model_visibility(); } /** * Given a string, and optional context, this decodes html entities if html_entity_decode is * enabled. * * @param string $str The string to decode * @param string $field_name The name of the field being encoded * @param bool $enabled Whether decoding is enabled by default for the string passed in * * @return string */ public function html_entity_decode( $str, $field_name, $enabled = false ) { /** * Determine whether html_entity_decode should be applied to the string * * @param bool $enabled Whether decoding is enabled by default for the string passed in * @param string $str The string to decode * @param string $field_name The name of the field being encoded * @param \WPGraphQL\Model\Model $model The Model the field is being decoded on * @hookGroup models * @since 0.15.0 */ $decoding_enabled = apply_filters( 'graphql_html_entity_decoding_enabled', $enabled, $str, $field_name, $this ); if ( false === $decoding_enabled ) { return $str; } return html_entity_decode( $str, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8' ); } /** * Filter the fields returned for the object * * @param string|string[]|null $fields The field or fields to build in the modeled object. Null to leave all fields. * @return void */ public function filter( $fields ) { if ( is_string( $fields ) ) { $fields = [ $fields ]; } if ( is_array( $fields ) ) { $this->fields = array_intersect_key( $this->fields, array_flip( $fields ) ); } } /** * Initialized the object. * * @return void */ abstract protected function init(); }