GetProduct.php
125 lines
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Automattic\WooCommerce\Api\Queries\Products; |
| 6 | |
| 7 | use Automattic\WooCommerce\Api\Attributes\Description; |
| 8 | use Automattic\WooCommerce\Api\Attributes\Name; |
| 9 | use Automattic\WooCommerce\Api\Attributes\RequiredCapability; |
| 10 | use Automattic\WooCommerce\Api\Attributes\ReturnType; |
| 11 | use Automattic\WooCommerce\Api\UnauthorizedException; |
| 12 | use Automattic\WooCommerce\Api\Interfaces\Product; |
| 13 | use Automattic\WooCommerce\Api\Utils\Products\ProductMapper; |
| 14 | |
| 15 | /** |
| 16 | * Query to retrieve a single product by ID. |
| 17 | * |
| 18 | * Demonstrates: authorize(), $_query_info, UnauthorizedException. |
| 19 | * |
| 20 | * Authorization logic: admins (manage_woocommerce) can read any product, |
| 21 | * non-admin users can only read their own products. |
| 22 | */ |
| 23 | #[Name( 'product' )] |
| 24 | #[Description( 'Retrieve a single product by ID.' )] |
| 25 | #[RequiredCapability( 'read_product' )] |
| 26 | class GetProduct { |
| 27 | /** |
| 28 | * Authorize access to a specific product. |
| 29 | * |
| 30 | * Admins can read any product. Non-admin users can only read products |
| 31 | * they authored themselves. |
| 32 | * |
| 33 | * Every inaccessible case throws `UnauthorizedException('Product not |
| 34 | * found.')` — whether the ID doesn't exist, points at a non-product |
| 35 | * post type, or points at a product the caller doesn't own. This |
| 36 | * prevents callers from enumerating product IDs vs non-product post |
| 37 | * IDs via the response they get back (which would otherwise be "not |
| 38 | * found" vs "no permission"). |
| 39 | * |
| 40 | * @param int $id The product ID. |
| 41 | * @param bool $_preauthorized Whether the declared capability check passed. |
| 42 | * @return bool Whether the current user can read this product. |
| 43 | * @throws UnauthorizedException When the product is not accessible. |
| 44 | */ |
| 45 | public function authorize( int $id, bool $_preauthorized ): bool { |
| 46 | // Reject non-positive IDs up front. `get_post( 0 )` inside a |
| 47 | // WordPress loop returns `$GLOBALS['post']` (not null), so a bare |
| 48 | // `get_post( $id )` below would accidentally operate on whatever |
| 49 | // global post was set upstream of this request. |
| 50 | if ( $id <= 0 ) { |
| 51 | throw new UnauthorizedException( 'Product not found.' ); |
| 52 | } |
| 53 | |
| 54 | $post = get_post( $id ); |
| 55 | |
| 56 | if ( ! $post || 'product' !== $post->post_type ) { |
| 57 | throw new UnauthorizedException( 'Product not found.' ); |
| 58 | } |
| 59 | |
| 60 | // Honor the declared #[RequiredCapability] (read_product). |
| 61 | if ( $_preauthorized ) { |
| 62 | return true; |
| 63 | } |
| 64 | |
| 65 | // `manage_woocommerce` is the canonical "admin sees everything" |
| 66 | // capability in WooCommerce. The declared #[RequiredCapability] |
| 67 | // pre-authorizes on `read_product` (the read-level post-type cap, |
| 68 | // which is what the schema advertises), but an admin whose cap set |
| 69 | // grants `manage_woocommerce` without `read_product` would |
| 70 | // otherwise fall through to the ownership check and get "Product |
| 71 | // not found" for any product they don't own — contrary to the |
| 72 | // documented admin-can-see-everything contract. |
| 73 | if ( current_user_can( 'manage_woocommerce' ) ) { |
| 74 | return true; |
| 75 | } |
| 76 | |
| 77 | // Non-admin users can only read their own products. Throw the same |
| 78 | // "not found" exception rather than returning false — a distinct |
| 79 | // "you don't have permission" error here would tell the caller |
| 80 | // that the ID is a product (just not theirs), leaking the |
| 81 | // product-ID space vs the rest of the post-ID space. |
| 82 | // |
| 83 | // Reject guest users explicitly: get_current_user_id() returns 0 |
| 84 | // for unauthenticated callers, and products created via WP-CLI, |
| 85 | // imports, or programmatic inserts without an author can have |
| 86 | // post_author = 0 — a bare `!==` check would mis-grant access to |
| 87 | // anonymous callers for those products. |
| 88 | $current_user_id = get_current_user_id(); |
| 89 | if ( 0 === $current_user_id || $current_user_id !== (int) $post->post_author ) { |
| 90 | throw new UnauthorizedException( 'Product not found.' ); |
| 91 | } |
| 92 | |
| 93 | return true; |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Retrieve a product by ID. |
| 98 | * |
| 99 | * @param int $id The product ID. |
| 100 | * @param ?array $_query_info Unified query info tree from the GraphQL request. |
| 101 | * @return ?object |
| 102 | */ |
| 103 | #[ReturnType( Product::class )] |
| 104 | public function execute( |
| 105 | #[Description( 'The ID of the product to retrieve.' )] |
| 106 | int $id, |
| 107 | ?array $_query_info = null, |
| 108 | ): ?object { |
| 109 | // Mirrors the guard in authorize(): never pass a non-positive ID to |
| 110 | // wc_get_product(). authorize() would normally reject these first, |
| 111 | // but a future caller path might invoke execute() directly. |
| 112 | if ( $id <= 0 ) { |
| 113 | return null; |
| 114 | } |
| 115 | |
| 116 | $wc_product = wc_get_product( $id ); |
| 117 | |
| 118 | if ( ! $wc_product instanceof \WC_Product ) { |
| 119 | return null; |
| 120 | } |
| 121 | |
| 122 | return ProductMapper::from_wc_product( $wc_product, $_query_info ); |
| 123 | } |
| 124 | } |
| 125 |