PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
jetpack / modules / related-posts / abilities / class-related-posts-abilities.php

class-related-posts-abilities.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at modules/related-posts/abilities/class-related-posts-abilities.php

264 lines 10.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Jetpack Related Posts Abilities Registration
4 *
5 * Registers Jetpack Related Posts abilities with the WordPress Abilities API.
6 *
7 * @package automattic/jetpack
8 */
9
10 namespace Automattic\Jetpack\Plugin\Abilities;
11
12 use Automattic\Jetpack\WP_Abilities\Registrar;
13 use Jetpack_RelatedPosts;
14 use WP_Error;
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit( 0 );
18 }
19
20 /**
21 * Registers Jetpack Related Posts abilities with the WordPress Abilities API.
22 *
23 * Exposes related-post lookups through the standard `wp-abilities/v1` REST
24 * surface. Display-settings management is intentionally not exposed: classic
25 * themes consume the `relatedposts` option only when an off-by-default filter
26 * is enabled, and block themes ignore it altogether (rendering is controlled
27 * per-instance by the Jetpack Related Posts block in templates) — so an agent
28 * editing those values would be writing data nothing reads.
29 */
30 class Related_Posts_Abilities extends Registrar {
31
32 // Mirrors the cap the upstream Related Posts ES query enforces; raising it
33 // here would be silently truncated downstream.
34 private const MAX_SIZE = 20;
35
36 private const DEFAULT_SIZE = 3;
37
38 /**
39 * Returns the category slug this registrar owns.
40 */
41 public static function get_category_slug(): string {
42 return 'jetpack-related-posts';
43 }
44
45 /**
46 * Returns the category definition passed to wp_register_ability_category().
47 */
48 public static function get_category_definition(): array {
49 return array(
50 // "Jetpack" is a product name and should not be translated.
51 'label' => 'Jetpack Related Posts',
52 'description' => __( 'Abilities for reading related posts.', 'jetpack' ),
53 );
54 }
55
56 /**
57 * Returns the abilities this registrar owns as a [ slug => spec ] map.
58 */
59 public static function get_abilities(): array {
60 $related_post_schema = array(
61 'type' => 'object',
62 'properties' => array(
63 'id' => array( 'type' => 'integer' ),
64 'url' => array( 'type' => 'string' ),
65 'title' => array( 'type' => 'string' ),
66 'excerpt' => array( 'type' => 'string' ),
67 'date' => array( 'type' => 'string' ),
68 'post_type' => array( 'type' => 'string' ),
69 'format' => array( 'type' => array( 'string', 'null' ) ),
70 ),
71 );
72
73 return array(
74 'jetpack-related-posts/get-related-posts' => array(
75 'label' => __( 'Get related posts', 'jetpack' ),
76 'description' => __( 'Return related posts for a single post as an array of { id, url, title, excerpt, date, post_type, format }. The caller must be able to edit the source post (edit_post capability); unauthorized requests return jetpack_related_posts_forbidden. Backed by Elasticsearch via the Jetpack connection: when Related Posts is disabled, the post is unknown, or the ES backend is unreachable, the array is empty (not an error). Use per_page to control the result count (1..20, default 20); the underlying Elasticsearch query is hard-capped at 20, so values above 20 are rejected by the input schema and pagination beyond the first 20 results is not supported. The legacy "size" alias is accepted for backward compatibility and defaults to 3 when no per_page is supplied. Read-only and idempotent. Use jetpack/get-modules to confirm the related-posts module is active, jetpack/set-module-status to activate it.', 'jetpack' ),
77 'input_schema' => array(
78 'type' => 'object',
79 'required' => array( 'post_id' ),
80 'properties' => array(
81 'post_id' => array(
82 'type' => 'integer',
83 'description' => __( 'WordPress post ID to find related posts for. Must reference an existing post.', 'jetpack' ),
84 'minimum' => 1,
85 ),
86 'per_page' => array(
87 'type' => 'integer',
88 'description' => __( 'Maximum number of related posts to return per call. Must be between 1 and 20 — the Elasticsearch backend hard-caps results at 20, so larger values are not supported and pagination past the first 20 results is unavailable. Defaults to 20.', 'jetpack' ),
89 'minimum' => 1,
90 'maximum' => self::MAX_SIZE,
91 'default' => self::MAX_SIZE,
92 ),
93 'size' => array(
94 'type' => 'integer',
95 'description' => __( 'Deprecated alias for per_page. Defaults to 3 when per_page is omitted. Capped at 20.', 'jetpack' ),
96 'minimum' => 1,
97 'maximum' => self::MAX_SIZE,
98 'default' => self::DEFAULT_SIZE,
99 ),
100 'post_type' => array(
101 'type' => 'string',
102 'description' => __( 'Restrict matches to a single post type slug (e.g. "post", "page"). Defaults to the source post\'s type.', 'jetpack' ),
103 'minLength' => 1,
104 ),
105 'exclude_post_ids' => array(
106 'type' => 'array',
107 'description' => __( 'Post IDs to exclude from the result.', 'jetpack' ),
108 'items' => array(
109 'type' => 'integer',
110 'minimum' => 1,
111 ),
112 'default' => array(),
113 ),
114 ),
115 'additionalProperties' => false,
116 ),
117 'output_schema' => array(
118 'type' => 'array',
119 'items' => $related_post_schema,
120 ),
121 'execute_callback' => array( __CLASS__, 'get_related_posts' ),
122 'permission_callback' => array( __CLASS__, 'can_view_related_posts' ),
123 'meta' => array(
124 'annotations' => array(
125 'readonly' => true,
126 'destructive' => false,
127 'idempotent' => true,
128 ),
129 'show_in_rest' => true,
130 'mcp' => array(
131 'public' => true,
132 'type' => 'tool', // default is already "tool", but can be explicit.
133 ),
134 ),
135 ),
136 );
137 }
138
139 /**
140 * Permission gate for the ability menu.
141 *
142 * Returns true if the caller can edit any post — keeps the ability listed
143 * for agents that have at least one editable post. The actual per-post
144 * authorization runs inside `get_related_posts()` once the source post_id
145 * is known, mirroring the existing /wpcom/v2/related-posts/{id} endpoint.
146 */
147 public static function can_view_related_posts(): bool {
148 return current_user_can( 'edit_posts' );
149 }
150
151 /**
152 * Execute: return related posts for a given post.
153 *
154 * @param array|null $input Input matching the ability's input_schema.
155 * @return array|WP_Error Array of related-post summaries, or WP_Error on validation failure.
156 */
157 public static function get_related_posts( $input = null ) {
158 $input = is_array( $input ) ? $input : array();
159
160 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
161 if ( $post_id <= 0 ) {
162 return new WP_Error(
163 'jetpack_related_posts_missing_post_id',
164 __( 'A post_id is required to fetch related posts.', 'jetpack' )
165 );
166 }
167
168 $post = get_post( $post_id );
169 if ( null === $post || empty( $post->ID ) ) {
170 return new WP_Error(
171 'jetpack_related_posts_invalid_post_id',
172 __( 'Unknown post ID. Verify the post exists and is accessible.', 'jetpack' )
173 );
174 }
175
176 // Match the per-post gate the existing /wpcom/v2/related-posts/{id}
177 // endpoint uses: the broad `edit_posts` cap on permission_callback lets
178 // the ability appear in the agent menu, but the actual lookup is
179 // authorized only when the caller can edit this specific post.
180 if ( ! current_user_can( 'edit_post', $post->ID ) ) {
181 return new WP_Error(
182 'jetpack_related_posts_forbidden',
183 __( 'You are not allowed to fetch related posts for this post.', 'jetpack' )
184 );
185 }
186
187 if ( isset( $input['per_page'] ) && is_int( $input['per_page'] ) ) {
188 // per_page wins when both are supplied; schema enforces the 1..MAX_SIZE
189 // range, the clamp here is defense in depth for direct callers that
190 // bypass schema validation.
191 $size = max( 1, min( self::MAX_SIZE, $input['per_page'] ) );
192 } elseif ( isset( $input['size'] ) && is_int( $input['size'] ) ) {
193 $size = max( 1, min( self::MAX_SIZE, $input['size'] ) );
194 } else {
195 $size = self::DEFAULT_SIZE;
196 }
197
198 $args = array( 'size' => $size );
199
200 if ( isset( $input['post_type'] ) && is_string( $input['post_type'] ) && '' !== $input['post_type'] ) {
201 $args['post_type'] = $input['post_type'];
202 }
203
204 if ( isset( $input['exclude_post_ids'] ) && is_array( $input['exclude_post_ids'] ) ) {
205 $args['exclude_post_ids'] = array_values(
206 array_filter(
207 array_map( 'intval', $input['exclude_post_ids'] ),
208 static function ( $id ) {
209 return $id > 0;
210 }
211 )
212 );
213 }
214
215 $results = self::related_posts_instance()->get_for_post_id( $post->ID, $args );
216 if ( ! is_array( $results ) || array() === $results ) {
217 return array();
218 }
219
220 // Prime the post cache once so `get_post_type()` inside summarize_related_post
221 // doesn't trigger N individual lookups when the cache is cold.
222 _prime_post_caches( wp_list_pluck( $results, 'id' ), false, false );
223
224 $out = array();
225 foreach ( $results as $related ) {
226 $out[] = self::summarize_related_post( $related );
227 }
228 return $out;
229 }
230
231 /**
232 * Returns the Jetpack Related Posts raw instance, loading the class file lazily
233 * when it has not been included by the module's own load action yet.
234 *
235 * @return \Jetpack_RelatedPosts
236 */
237 private static function related_posts_instance() {
238 if ( ! class_exists( Jetpack_RelatedPosts::class, false ) ) {
239 require_once __DIR__ . '/../jetpack-related-posts.php';
240 }
241 return Jetpack_RelatedPosts::init_raw();
242 }
243
244 /**
245 * Reduce the rich Related Posts result to a high-signal summary.
246 *
247 * @param array $related Single related-post entry from get_for_post_id().
248 * @return array
249 */
250 private static function summarize_related_post( array $related ): array {
251 $id = isset( $related['id'] ) ? (int) $related['id'] : 0;
252
253 return array(
254 'id' => $id,
255 'url' => isset( $related['url'] ) ? (string) $related['url'] : '',
256 'title' => isset( $related['title'] ) ? (string) $related['title'] : '',
257 'excerpt' => isset( $related['excerpt'] ) ? (string) $related['excerpt'] : '',
258 'date' => isset( $related['date'] ) ? (string) $related['date'] : '',
259 'post_type' => $id > 0 ? (string) get_post_type( $id ) : '',
260 'format' => isset( $related['format'] ) && '' !== $related['format'] ? (string) $related['format'] : null,
261 );
262 }
263 }
264