PluginProbe
WPGraphQL / trunk
WPGraphQL vtrunk
2.22.3 2.22.2 2.22.1 2.22.0 2.21.1 2.21.0 2.20.0 2.19.0 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.1 2.14.0 2.13.0 2.2.0 2.3.0 2.3.3 2.3.6 2.3.8 2.5.0 2.5.1 2.5.2 2.5.3 All 177 releases
wp-graphql / src / Model / Model.php

Model.php in WPGraphQL trunk, at src/Model/Model.php

605 lines 20.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPGraphQL\Model;
4
5 use Exception;
6
7 /**
8 * Class Model - Abstract class for modeling data for all core types
9 *
10 * @property bool $isPrivate
11 * @property bool $isPublic
12 * @property bool $isRestricted
13 *
14 * @template TData
15 */
16 abstract class Model {
17
18 /**
19 * Stores the name of the type the child class extending this one represents
20 *
21 * @var string
22 */
23 protected $model_name;
24
25 /**
26 * Stores the raw data passed to the child class when it's instantiated before it's transformed
27 *
28 * @var TData
29 */
30 protected $data;
31
32 /**
33 * Stores the capability name for what to check on the user if the data should be considered
34 * "Restricted"
35 *
36 * @var string
37 */
38 protected $restricted_cap;
39
40 /**
41 * Stores the array of allowed fields to show if the data is restricted
42 *
43 * @var string[]
44 */
45 protected $allowed_restricted_fields;
46
47 /**
48 * Stores the DB ID of the user that owns this piece of data, or null if there is no owner
49 *
50 * @var int|null
51 */
52 protected $owner;
53
54 /**
55 * Stores the WP_User object for the current user in the session
56 *
57 * @var \WP_User $current_user
58 */
59 protected $current_user;
60
61 /**
62 * Stores the visibility value for the current piece of data
63 *
64 * @var string
65 */
66 protected $visibility;
67
68 /**
69 * The fields for the modeled object. This will be populated in the child class
70 *
71 * @var array<string,mixed>
72 */
73 public $fields;
74
75 /**
76 * Model constructor.
77 *
78 * @param string $restricted_cap The capability to check against to determine if
79 * the data should be restricted or not
80 * @param string[] $allowed_restricted_fields The allowed fields if the data is in fact restricted
81 * @param int|null $owner Database ID of the user that owns this piece of
82 * data to compare with the current user ID
83 *
84 * @return void
85 * @throws \Exception Throws Exception.
86 */
87 protected function __construct( $restricted_cap = '', $allowed_restricted_fields = [], $owner = null ) {
88 if ( empty( $this->data ) ) {
89 // translators: %s is the name of the model.
90 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() ) ) );
91 }
92
93 $this->restricted_cap = $restricted_cap;
94 $this->allowed_restricted_fields = $allowed_restricted_fields;
95 $this->owner = $owner;
96 $this->current_user = wp_get_current_user();
97
98 if ( 'private' === $this->get_visibility() ) {
99 return;
100 }
101
102 $this->init();
103 $this->prepare_fields();
104 }
105
106 /**
107 * Magic method to re-map the isset check on the child class looking for properties when
108 * resolving the fields
109 *
110 * @param string $key The name of the field you are trying to retrieve
111 *
112 * @return bool
113 */
114 public function __isset( $key ) {
115 return isset( $this->fields[ $key ] );
116 }
117
118 /**
119 * Magic method to re-map setting new properties to the class inside of the $fields prop rather
120 * than on the class in unique properties
121 *
122 * @param string $key Name of the key to set the data to
123 * @param callable|int|string|mixed $value The value to set to the key
124 *
125 * @return void
126 */
127 public function __set( $key, $value ) {
128 $this->fields[ $key ] = $value;
129 }
130
131 /**
132 * Magic method to re-map where external calls go to look for properties on the child objects.
133 * This is crucial to let objects modeled through this class work with the default field
134 * resolver.
135 *
136 * @param string $key Name of the property that is trying to be accessed
137 *
138 * @return mixed|null
139 */
140 public function __get( $key ) {
141 if ( ! array_key_exists( $key, $this->fields ) ) {
142 return null;
143 }
144
145 // Unresolved fields are closures (see wrap_fields()); resolve and memoize them.
146 // Anything else is already-resolved data, including strings that happen to
147 // collide with a defined function name ("Max" vs max()), which is_callable()
148 // would match case-insensitively and invoke the builtin instead of returning
149 // the value.
150 if ( $this->fields[ $key ] instanceof \Closure ) {
151 $data = call_user_func( $this->fields[ $key ] );
152 $this->$key = $data;
153
154 return $data;
155 }
156
157 return $this->fields[ $key ];
158 }
159
160 /**
161 * Setup the global state before each field is resolved so the Model has the necessary context.
162 *
163 * @return void
164 */
165 public function setup() {
166 }
167
168 /**
169 * Tear-down call that runs after each field is resolved.
170 *
171 * This can be used to reset state to where it was before the model was setup.
172 *
173 * @return void
174 */
175 public function tear_down() {
176 }
177
178 /**
179 * Returns the name of the model, built from the child className
180 *
181 * @return string
182 */
183 protected function get_model_name() {
184 if ( empty( $this->model_name ) ) {
185 $name = static::class;
186
187 if ( false !== strpos( static::class, '\\' ) ) {
188 $starting_character = strrchr( static::class, '\\' );
189 if ( ! empty( $starting_character ) ) {
190 $name = substr( $starting_character, 1 );
191 }
192 }
193 $this->model_name = $name . 'Object';
194 }
195
196 return $this->model_name;
197 }
198
199 /**
200 * Return the visibility state for the current piece of data
201 *
202 * @return string|null
203 */
204 public function get_visibility() {
205 if ( null === $this->visibility ) {
206
207 /**
208 * Filter for the capability to check against for restricted data
209 *
210 * @param string $restricted_cap The capability to check against
211 * @param string $model_name Name of the model the filter is currently being executed in
212 * @param TData $data The un-modeled incoming data
213 * @param string|null $visibility The visibility that has currently been set for the data at this point
214 * @param int|null $owner The user ID for the owner of this piece of data
215 * @param \WP_User $current_user The current user for the session
216 *
217 * @hookGroup models
218 * @since 0.3.0
219 * @return string
220 */
221 $protected_cap = apply_filters( 'graphql_restricted_data_cap', $this->restricted_cap, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
222
223 /**
224 * Filter to short circuit default is_private check for the model. This is expensive in some cases so
225 * this filter lets you prevent this from running by returning a true or false value.
226 *
227 * @param ?bool $is_private Whether the model data is private. Defaults to null.
228 * @param string $model_name Name of the model the filter is currently being executed in
229 * @param TData $data The un-modeled incoming data
230 * @param string|null $visibility The visibility that has currently been set for the data at this point
231 * @param int|null $owner The user ID for the owner of this piece of data
232 * @param \WP_User $current_user The current user for the session
233 *
234 * @hookGroup models
235 * @since 1.1.3
236 * @return bool|null
237 */
238 $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 );
239
240 // If 3rd party code has not filtered this, use the Models default logic to determine
241 // whether the model should be considered private
242 if ( null !== $pre_is_private ) {
243 $is_private = $pre_is_private;
244 } else {
245 $is_private = $this->is_private();
246 }
247
248 /**
249 * Filter to determine if the data should be considered private or not
250 *
251 * @param bool $is_private Whether the model is private
252 * @param string $model_name Name of the model the filter is currently being executed in
253 * @param TData $data The un-modeled incoming data
254 * @param string|null $visibility The visibility that has currently been set for the data at this point
255 * @param int|null $owner The user ID for the owner of this piece of data
256 * @param \WP_User $current_user The current user for the session
257 *
258 * @hookGroup models
259 * @since 0.3.0
260 * @return bool
261 */
262 $is_private = apply_filters( 'graphql_data_is_private', (bool) $is_private, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
263
264 if ( true === $is_private ) {
265 $this->visibility = 'private';
266 } elseif ( null !== $this->owner && true === $this->owner_matches_current_user() ) {
267 $this->visibility = 'public';
268 } elseif ( empty( $protected_cap ) || current_user_can( $protected_cap ) ) {
269 $this->visibility = 'public';
270 } else {
271 $this->visibility = 'restricted';
272 }
273 }
274
275 /**
276 * Filter the visibility name to be returned
277 *
278 * @param string|null $visibility The visibility that has currently been set for the data at this point
279 * @param string $model_name Name of the model the filter is currently being executed in
280 * @param TData $data The un-modeled incoming data
281 * @param int|null $owner The user ID for the owner of this piece of data
282 * @param \WP_User $current_user The current user for the session
283 *
284 * @hookGroup models
285 * @since 0.3.0
286 * @return string
287 */
288 return apply_filters( 'graphql_object_visibility', $this->visibility, $this->get_model_name(), $this->data, $this->owner, $this->current_user );
289 }
290
291 /**
292 * Method to return the private state of the object. Can be overwritten in classes extending
293 * this one.
294 *
295 * @return bool
296 */
297 protected function is_private() {
298 return false;
299 }
300
301 /**
302 * Whether or not the owner of the data matches the current user
303 *
304 * @return bool
305 */
306 protected function owner_matches_current_user() {
307 if ( empty( $this->current_user->ID ) || empty( $this->owner ) ) {
308 return false;
309 }
310
311 return absint( $this->owner ) === absint( $this->current_user->ID );
312 }
313
314 /**
315 * Restricts fields for the data to only return the allowed fields if the data is restricted
316 *
317 * @return void
318 */
319 protected function restrict_fields() {
320 $this->fields = array_intersect_key(
321 $this->fields,
322 array_flip(
323 /**
324 * Filter for the allowed restricted fields
325 *
326 * @param string[] $allowed_restricted_fields The fields to allow when the data is designated as restricted to the current user
327 * @param string $model_name Name of the model the filter is currently being executed in
328 * @param TData $data The un-modeled incoming data
329 * @param string|null $visibility The visibility that has currently been set for the data at this point
330 * @param int|null $owner The user ID for the owner of this piece of data
331 * @param \WP_User $current_user The current user for the session
332 */
333 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 )
334 )
335 );
336 }
337
338 /**
339 * Wraps all fields with another callback layer so we can inject hooks & filters into them
340 *
341 * @return void
342 */
343 protected function wrap_fields() {
344 if ( ! is_array( $this->fields ) || empty( $this->fields ) ) {
345 return;
346 }
347
348 $clean_array = [];
349 foreach ( $this->fields as $key => $data ) {
350 $clean_array[ $key ] = function () use ( $key, $data ) {
351 /**
352 * Filter to short circuit the callback for any field on a type.
353 *
354 * Returning anything other than null will stop the callback for the field from executing,
355 * and will return your data or execute your callback instead.
356 *
357 * @param mixed $result The data returned from the callback. Null by default.
358 * @param string $key The name of the field on the type
359 * @param string $model_name Name of the model the filter is currently being executed in
360 * @param TData $data The un-modeled incoming data
361 * @param string $visibility The visibility setting for this piece of data
362 * @param int|null $owner The user ID for the owner of this piece of data
363 * @param \WP_User $current_user The current user for the session
364 * @hookGroup models
365 * @since 0.3.0
366 */
367 $pre = apply_filters( 'graphql_pre_return_field_from_model', null, $key, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
368
369 if ( ! is_null( $pre ) ) {
370 // If the pre filter returns a value, we use that instead of the callback.
371 $result = $pre;
372 } else {
373 $result = $this->prepare_field( $key, $data );
374 }
375
376 /**
377 * Hook that fires after the data is returned for the field
378 *
379 * @param mixed $result The returned data for the field
380 * @param string $key The name of the field on the type
381 * @param string $model_name Name of the model the filter is currently being executed in
382 * @param TData $data The un-modeled incoming data
383 * @param string $visibility The visibility setting for this piece of data
384 * @param int|null $owner The user ID for the owner of this piece of data
385 * @param \WP_User $current_user The current user for the session
386 * @hookGroup models
387 * @since 0.3.0
388 */
389 do_action( 'graphql_after_return_field_from_model', $result, $key, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
390
391 return $result;
392 };
393 }
394
395 $this->fields = $clean_array;
396 }
397
398 /**
399 * Prepares an individual field for the model.
400 *
401 * @param string $field_name The name of the field on the type
402 * @param TData $field The field data to prepare.
403 *
404 * @return TData
405 */
406 private function prepare_field( string $field_name, $field ) {
407 $can_access_field = $this->current_user_can_access_field( $field_name, $field );
408
409 // If the field is an array with a 'callback', use that as the callback.
410 if ( is_array( $field ) && ! empty( $field['callback'] ) ) {
411 $field = $field['callback'];
412 }
413
414 // If the user doesn't have access to the field, sanitize it to null.
415 if ( ! $can_access_field ) {
416 $field = null;
417 }
418
419 // Invoke Closures and callable arrays (the resolver shapes WPGraphQL installs),
420 // but never a bare callable string. A field definition that is a string is data,
421 // not a resolver we registered (for example a value that happens to match a PHP
422 // function name), so it must be returned rather than invoked. This mirrors the
423 // Closure gate in __get(). Defense in depth for GHSA-7922 / CVE-2026-18944.
424 if ( is_callable( $field ) && ! is_string( $field ) ) {
425 $this->setup();
426 $field = call_user_func( $field );
427 $this->tear_down();
428 }
429
430 /**
431 * Filter the data returned by the default callback for the field
432 *
433 * @param mixed $field The data returned from the callback
434 * @param string $field_name The name of the field on the type
435 * @param string $model_name Name of the model the filter is currently being executed in
436 * @param TData $data The un-modeled incoming data
437 * @param string $visibility The visibility setting for this piece of data
438 * @param int|null $owner The user ID for the owner of this piece of data
439 * @param \WP_User $current_user The current user for the session
440 * @hookGroup models
441 * @since 0.3.0
442 */
443 return apply_filters( 'graphql_return_field_from_model', $field, $field_name, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
444 }
445
446 /**
447 * Returns the capability to check for the field, or null if there is no capability set.
448 *
449 * @uses 'graphql_model_field_capability' to filter the capability to check for the field.
450 *
451 * @param string $field_name The name of the field to check
452 * @param mixed $field The original metadata for the field.
453 */
454 private function current_user_can_access_field( string $field_name, $field ): bool {
455 $capability = '';
456
457 // If the field metadata is an array, check for the capability key
458 if ( is_array( $field ) && isset( $field['capability'] ) ) {
459 $capability = (string) $field['capability'];
460 }
461
462 /**
463 * Capability to check required for the field
464 *
465 * @param string $capability The capability to check against to return the field
466 * @param string $field_name The name of the field on the type
467 * @param string $model_name Name of the model the filter is currently being executed in
468 * @param TData $data The un-modeled incoming data
469 * @param string $visibility The visibility setting for this piece of data
470 * @param int|null $owner The user ID for the owner of this piece of data
471 * @param \WP_User $current_user The current user for the session
472 * @hookGroup models
473 * @since 0.3.0
474 */
475 $capability = apply_filters( 'graphql_model_field_capability', $capability, $field_name, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
476
477 if ( empty( $capability ) ) {
478 return true;
479 }
480
481 // @todo add support passing capability args.
482 if ( current_user_can( $capability ) ) {
483 return true;
484 }
485
486 // Surface the denial in debug mode so the resulting null is
487 // discoverable rather than silent.
488 graphql_debug(
489 sprintf(
490 // translators: 1: model + field name, 2: required WordPress capability.
491 __( 'The "%1$s" field requires the "%2$s" capability and resolved to null.', 'wp-graphql' ),
492 $this->get_model_name() . '.' . $field_name,
493 $capability
494 ),
495 [
496 'type' => 'RESTRICTED_FIELD',
497 'field' => $this->get_model_name() . '.' . $field_name,
498 'required_capability' => $capability,
499 ]
500 );
501
502 return false;
503 }
504
505 /**
506 * Adds the model visibility fields to the data
507 */
508 private function add_model_visibility(): void {
509
510 /**
511 * @todo: potentially abstract this out into a more central spot
512 */
513 $this->fields['isPublic'] = function () {
514 return 'public' === $this->get_visibility();
515 };
516 $this->fields['isRestricted'] = function () {
517 return 'restricted' === $this->get_visibility();
518 };
519 $this->fields['isPrivate'] = function () {
520 return 'private' === $this->get_visibility();
521 };
522 }
523
524 /**
525 * Returns instance of the data fully modeled
526 *
527 * @return void
528 */
529 protected function prepare_fields() {
530 if ( 'restricted' === $this->get_visibility() ) {
531 $this->restrict_fields();
532 }
533
534 /**
535 * Filter the array of fields for the Model before the object is hydrated with it
536 *
537 * @param array<string,mixed> $fields The array of fields for the model
538 * @param string $model_name Name of the model the filter is currently being executed in
539 * @param TData $data The un-modeled incoming data
540 * @param string $visibility The visibility setting for this piece of data
541 * @param ?int $owner The user ID for the owner of this piece of data
542 * @param \WP_User $current_user The current user for the session
543 * @hookGroup models
544 * @since 1.7.0
545 */
546 $this->fields = apply_filters( 'graphql_model_prepare_fields', $this->fields, $this->get_model_name(), $this->data, $this->visibility, $this->owner, $this->current_user );
547 $this->wrap_fields();
548 $this->add_model_visibility();
549 }
550
551 /**
552 * Given a string, and optional context, this decodes html entities if html_entity_decode is
553 * enabled.
554 *
555 * @param string $str The string to decode
556 * @param string $field_name The name of the field being encoded
557 * @param bool $enabled Whether decoding is enabled by default for the string passed in
558 *
559 * @return string
560 */
561 public function html_entity_decode( $str, $field_name, $enabled = false ) {
562
563 /**
564 * Determine whether html_entity_decode should be applied to the string
565 *
566 * @param bool $enabled Whether decoding is enabled by default for the string passed in
567 * @param string $str The string to decode
568 * @param string $field_name The name of the field being encoded
569 * @param \WPGraphQL\Model\Model $model The Model the field is being decoded on
570 * @hookGroup models
571 * @since 0.15.0
572 */
573 $decoding_enabled = apply_filters( 'graphql_html_entity_decoding_enabled', $enabled, $str, $field_name, $this );
574
575 if ( false === $decoding_enabled ) {
576 return $str;
577 }
578
579 return html_entity_decode( $str, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8' );
580 }
581
582 /**
583 * Filter the fields returned for the object
584 *
585 * @param string|string[]|null $fields The field or fields to build in the modeled object. Null to leave all fields.
586 * @return void
587 */
588 public function filter( $fields ) {
589 if ( is_string( $fields ) ) {
590 $fields = [ $fields ];
591 }
592
593 if ( is_array( $fields ) ) {
594 $this->fields = array_intersect_key( $this->fields, array_flip( $fields ) );
595 }
596 }
597
598 /**
599 * Initialized the object.
600 *
601 * @return void
602 */
603 abstract protected function init();
604 }
605