PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.5
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.5
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / bulk-editor / user-interface / posts-content-route.php

posts-content-route.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.5, at src/bulk-editor/user-interface/posts-content-route.php

180 lines 6.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4 namespace Yoast\WP\SEO\Bulk_Editor\User_Interface;
5
6 use WP_Post;
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use Yoast\WP\SEO\Bulk_Editor\Application\Posts\Posts_Collector_Interface;
10 use Yoast\WP\SEO\Bulk_Editor\Application\Updates\Post_Access_Checker_Interface;
11 use Yoast\WP\SEO\Conditionals\No_Conditionals;
12 use Yoast\WP\SEO\Main;
13 use Yoast\WP\SEO\Routes\Route_Interface;
14
15 /**
16 * Registers a route that returns the raw content of the requested posts.
17 *
18 * Serves the AI bulk suggestions flow, which collects each post's prompt content in the browser (so it runs through
19 * the same analysis engine as the in-editor AI generator) and therefore needs the unrendered `post_content`. It is
20 * deliberately separate from the posts route: the table listing does not need content, and keeping it out of that
21 * payload keeps page loads lean.
22 */
23 class Posts_Content_Route implements Route_Interface {
24
25 use No_Conditionals;
26
27 /**
28 * The namespace for this route.
29 *
30 * @var string
31 */
32 public const ROUTE_NAMESPACE = Main::API_V1_NAMESPACE;
33
34 /**
35 * The prefix for this route.
36 *
37 * @var string
38 */
39 public const ROUTE_PREFIX = '/bulk_editor/posts_content';
40
41 /**
42 * The maximum number of posts whose content can be requested at once.
43 *
44 * Matches the maximum number of posts a single AI bulk suggestions request accepts, since that is the only
45 * consumer: there is never a reason to ask for more in one call.
46 *
47 * @var int
48 */
49 public const MAX_IDS = 20;
50
51 /**
52 * The post access checker.
53 *
54 * @var Post_Access_Checker_Interface
55 */
56 private $post_access_checker;
57
58 /**
59 * The constructor.
60 *
61 * @param Post_Access_Checker_Interface $post_access_checker The post access checker.
62 */
63 public function __construct( Post_Access_Checker_Interface $post_access_checker ) {
64 $this->post_access_checker = $post_access_checker;
65 }
66
67 /**
68 * Registers routes with WordPress.
69 *
70 * @return void
71 */
72 public function register_routes() {
73 \register_rest_route(
74 self::ROUTE_NAMESPACE,
75 self::ROUTE_PREFIX,
76 [
77 'methods' => 'GET',
78 'args' => [
79 'ids' => [
80 'required' => true,
81 'type' => 'array',
82 'minItems' => 1,
83 'maxItems' => self::MAX_IDS,
84 'items' => [
85 'type' => 'integer',
86 'minimum' => 1,
87 ],
88 'description' => 'The IDs of the posts to return the content of. Accepts a comma separated list.',
89 ],
90 ],
91 'callback' => [ $this, 'get_posts_content' ],
92 'permission_callback' => [ $this, 'check_permissions' ],
93 ],
94 );
95 }
96
97 /**
98 * Returns the raw content of the requested posts.
99 *
100 * Posts that no longer exist, are not of an editable type, that the current user may not edit, or that the
101 * bulk editor does not list are omitted from the response rather than failing the request: one inaccessible
102 * post out of a selection should not cost the caller the whole batch. The caller treats an absent ID as
103 * "no content available" for that row.
104 *
105 * @param WP_REST_Request $request The request object.
106 *
107 * @return WP_REST_Response The posts and their raw content.
108 */
109 public function get_posts_content( WP_REST_Request $request ): WP_REST_Response {
110 /*
111 * The `ids` argument declares `type: array` in the schema, so WordPress has already normalized it by the
112 * time the callback runs: `rest_sanitize_array()` puts a scalar through `wp_parse_list()`, which means a
113 * comma separated `ids=11,22` arrives here as [ 11, 22 ] rather than as one string. The cast therefore only
114 * guards a non-array reaching this method from a direct call.
115 */
116 $post_ids = \array_unique( \array_map( '\intval', (array) $request->get_param( 'ids' ) ) );
117
118 /*
119 * Prime the post cache in one query so the checks below do not run one per post, the same way
120 * Post_Editability_Resolver does for the listing. Neither the meta nor the term cache is needed:
121 * only the post row itself is read.
122 */
123 \_prime_post_caches( $post_ids, false, false );
124
125 $posts = [];
126 foreach ( $post_ids as $post_id ) {
127 if ( ! $this->post_access_checker->exists( $post_id )
128 || ! $this->post_access_checker->is_supported_type( $post_id )
129 || ! $this->post_access_checker->can_edit( $post_id )
130 ) {
131 continue;
132 }
133
134 $post = \get_post( $post_id );
135 // The access check above already resolved the post, but do not rely on that holding across two
136 // lookups: a post that is gone is omitted like any other inaccessible ID, never fataling the batch.
137 if ( $post === null || ! $this->is_listed( $post ) ) {
138 continue;
139 }
140
141 $posts[] = [
142 'id' => $post_id,
143 // The stored content, unrendered: the client parses it with the analysis engine, exactly as the
144 // post editor does, so blocks and shortcodes are handled there rather than by render filters here.
145 'content' => (string) $post->post_content,
146 ];
147 }
148
149 return new WP_REST_Response( [ 'posts' => $posts ] );
150 }
151
152 /**
153 * Whether the post is on the bulk editor lists.
154 *
155 * The content served here has to stay in step with the table: a post that has no row cannot have a suggestion
156 * generated for it, so serving its content would only widen what this route exposes. Both collectors narrow on
157 * the same two properties — the bulk editor's post statuses, and password-protected posts being left out of
158 * bulk editing (the indexable collector filters those on `is_protected`, which is derived from `post_password`).
159 *
160 * @param WP_Post $post The post to check.
161 *
162 * @return bool Whether the post is on the bulk editor lists.
163 */
164 private function is_listed( WP_Post $post ): bool {
165 return \in_array( $post->post_status, Posts_Collector_Interface::STATUSES, true )
166 && $post->post_password === '';
167 }
168
169 /**
170 * Checks whether the current user is allowed to use the bulk editor.
171 *
172 * The per-post edit capability is enforced per requested ID in {@see get_posts_content()}.
173 *
174 * @return bool Whether the current user is allowed to use the bulk editor.
175 */
176 public function check_permissions(): bool {
177 return \current_user_can( 'wpseo_manage_options' );
178 }
179 }
180