PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / app / Agent / Controllers / ContentController.php

ContentController.php in Extendify 3.2.1, at app/Agent/Controllers/ContentController.php

401 lines 12.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Extendify\Agent\Controllers;
4
5 defined('ABSPATH') || die('No direct access.');
6
7 class ContentController
8 {
9 // phpcs:disable PSR12.Properties.ConstantVisibility.NotFound
10 // Header and footer text lives in a template part, a synced pattern's in a wp_block.
11 const POST_TYPES = ['post', 'page', 'wp_template_part', 'wp_block'];
12
13 const FORMATS = ['text', 'blocks', 'excerpt'];
14
15 const MAX_SCANNED = 100;
16
17 const MAX_POSTS = 25;
18
19 const MAX_MATCHES = 3;
20
21 const MAX_CONTEXT = 300;
22
23 const MAX_CONTENT = 15000;
24
25 const MAX_BYTES = 20000;
26
27 const MAX_META_KEYS = 50;
28
29 const MAX_META_VALUE = 500;
30 // phpcs:enable PSR12.Properties.ConstantVisibility.NotFound
31
32 /**
33 * @param \WP_REST_Request $request The REST API request object.
34 * @return \WP_REST_Response
35 */
36 public static function search(\WP_REST_Request $request)
37 {
38 $params = self::params($request);
39 $query = trim(self::text($params, 'query'));
40 if ($query === '') {
41 return self::reject('query must say what to look for');
42 }
43
44 $types = self::types($params);
45 if (!$types) {
46 return self::reject('postTypes names no post type this site has');
47 }
48
49 $limit = self::number($params, 'limit', 10, self::MAX_POSTS);
50 $context = self::number($params, 'context', 60, self::MAX_CONTEXT);
51 $candidates = new \WP_Query([
52 'post_type' => $types,
53 'post_status' => 'publish',
54 'posts_per_page' => self::MAX_SCANNED,
55 'orderby' => 'modified',
56 'order' => 'DESC',
57 'ignore_sticky_posts' => true,
58 ]);
59
60 $results = [];
61 $matched = 0;
62 $spent = 0;
63 foreach ($candidates->posts as $post) {
64 $found = self::snippets(self::rendered($post), $query, $context);
65 if (!$found['matches']) {
66 continue;
67 }
68
69 $matched++;
70 if (count($results) >= $limit || $spent > self::MAX_BYTES) {
71 continue;
72 }
73
74 $result = [
75 'id' => (int) $post->ID,
76 'type' => $post->post_type,
77 'title' => self::decoded($post->post_title),
78 'slug' => $post->post_name,
79 'matches' => $found['matches'],
80 'moreMatches' => $found['count'] - count($found['matches']),
81 ];
82 // Escaped JSON counts one Japanese character as six, spending the budget six times over.
83 $spent += strlen((string) wp_json_encode($result, JSON_UNESCAPED_UNICODE));
84 $results[] = $result;
85 }
86
87 $response = [
88 'query' => $query,
89 'scanned' => count($candidates->posts),
90 'matched' => $matched,
91 'results' => $results,
92 ];
93 $left = self::leftOut($matched - count($results), (int) $candidates->found_posts - count($candidates->posts));
94 if ($left !== '') {
95 $response['truncated'] = $left;
96 }
97
98 return new \WP_REST_Response($response);
99 }
100
101 /**
102 * @param \WP_REST_Request $request The REST API request object.
103 * @return \WP_REST_Response
104 */
105 public static function read(\WP_REST_Request $request)
106 {
107 $params = self::params($request);
108 $id = self::number($params, 'id', 0, PHP_INT_MAX);
109 $slug = trim(self::text($params, 'slug'));
110 if (!$id && $slug === '') {
111 return self::reject('id or slug must say what to read');
112 }
113
114 $format = self::text($params, 'format', 'text');
115 if (!in_array($format, self::FORMATS, true)) {
116 return self::reject('format must be one of ' . implode(', ', self::FORMATS));
117 }
118
119 $post = $id ? self::byId($id) : self::bySlug($slug);
120 if (!$post) {
121 return self::reject('Nothing readable was found for ' . ($id ? $id : $slug));
122 }
123
124 $content = self::content($post, $format);
125 $response = [
126 'id' => (int) $post->ID,
127 'type' => $post->post_type,
128 'title' => self::decoded($post->post_title),
129 'slug' => $post->post_name,
130 'status' => $post->post_status,
131 'date' => $post->post_date,
132 'format' => $format,
133 'content' => mb_substr($content, 0, self::MAX_CONTENT),
134 ];
135 if (mb_strlen($content) > self::MAX_CONTENT) {
136 $response['truncated'] = 'Cut after ' . self::MAX_CONTENT . ' characters, of '
137 . mb_strlen($content) . ' the post holds.';
138 }
139 if (!empty($params['includeMeta'])) {
140 $response['meta'] = self::meta($post->ID);
141 }
142
143 return new \WP_REST_Response($response);
144 }
145
146 /**
147 * @param \WP_REST_Request $request The REST API request object.
148 * @return array The request body.
149 */
150 private static function params(\WP_REST_Request $request)
151 {
152 $params = $request->get_json_params();
153
154 return is_array($params) ? $params : [];
155 }
156
157 /**
158 * @param string $message What the caller has to change.
159 * @return \WP_REST_Response
160 */
161 private static function reject($message)
162 {
163 return new \WP_REST_Response(['error' => $message], 400);
164 }
165
166 /**
167 * @param array $params The array to read from.
168 * @param string $key The key to read.
169 * @param string $default What to use when the key is absent.
170 * @return string
171 */
172 private static function text($params, $key, $default = '')
173 {
174 return isset($params[$key]) && is_string($params[$key]) ? $params[$key] : $default;
175 }
176
177 /**
178 * @param array $params The array to read from.
179 * @param string $key The key to read.
180 * @param integer $default What to use when the key is absent.
181 * @param integer $most The largest value to allow.
182 * @return integer
183 */
184 private static function number($params, $key, $default, $most)
185 {
186 $value = isset($params[$key]) && is_numeric($params[$key])
187 ? (int) $params[$key]
188 : $default;
189
190 return max(0, min($value, $most));
191 }
192
193 /**
194 * @param array $params The request body.
195 * @return array The post types to search through.
196 */
197 private static function types($params)
198 {
199 $requested = isset($params['postTypes']) && is_array($params['postTypes'])
200 ? $params['postTypes']
201 : [];
202 if (!$requested) {
203 return self::POST_TYPES;
204 }
205
206 return array_values(array_filter($requested, function ($type) {
207 return is_string($type) && post_type_exists($type);
208 }));
209 }
210
211 /**
212 * An empty result and one nobody looked for read the same to a model.
213 *
214 * @param integer $matched Matching posts the response left out.
215 * @param integer $unscanned Candidates the scan never reached.
216 * @return string
217 */
218 private static function leftOut($matched, $unscanned)
219 {
220 $left = [];
221 if ($matched > 0) {
222 $left[] = $matched . ' more posts matched';
223 }
224 if ($unscanned > 0) {
225 $left[] = $unscanned . ' posts were not searched';
226 }
227
228 return $left ? implode(', and ', $left) . '.' : '';
229 }
230
231 /**
232 * Blocks that read the post they sit in render empty without the global.
233 *
234 * @param \WP_Post $post The post to render.
235 * @return string Its text as a visitor reads it.
236 */
237 private static function rendered($post)
238 {
239 $GLOBALS['post'] = $post;
240 setup_postdata($post);
241 $html = do_shortcode(do_blocks($post->post_content));
242 wp_reset_postdata();
243
244 return trim(preg_replace('/\s+/u', ' ', self::decoded(wp_strip_all_tags($html))));
245 }
246
247 /**
248 * @param string $text Text as WordPress stored or rendered it.
249 * @return string
250 */
251 private static function decoded($text)
252 {
253 return html_entity_decode($text, ENT_QUOTES, get_bloginfo('charset'));
254 }
255
256 /**
257 * @param string $text The text to look through.
258 * @param string $query What to look for.
259 * @param integer $context How much to quote either side of a match.
260 * @return array The snippets, and how many times the text says it.
261 */
262 private static function snippets($text, $query, $context)
263 {
264 // Turkish İ lower cases to two characters, so a folded copy's offsets miss the match.
265 $pattern = '/' . preg_quote($query, '/') . '/iu';
266 if (!preg_match_all($pattern, $text, $found, PREG_OFFSET_CAPTURE)) {
267 return ['matches' => [], 'count' => 0];
268 }
269
270 $end = strlen($text);
271 $matches = [];
272 $quoted = 0;
273 foreach ($found[0] as $occurrence) {
274 list($match, $at) = $occurrence;
275 if (count($matches) >= self::MAX_MATCHES || $at < $quoted) {
276 continue;
277 }
278
279 $before = self::quoteBefore($text, $at, $context);
280 $after = self::quoteAfter($text, $at + strlen($match), $context);
281 $quoted = $at + strlen($match) + strlen($after);
282 $matches[] = ($at > strlen($before) ? '…' : '')
283 . $before . $match . $after
284 . ($quoted < $end ? '…' : '');
285 }
286
287 return ['matches' => $matches, 'count' => count($found[0])];
288 }
289
290 /**
291 * Up to $context characters of $text ending at byte offset $at.
292 *
293 * @param string $text The text a match was found in.
294 * @param integer $at Where the match begins, in bytes.
295 * @param integer $context How many characters to quote.
296 * @return string
297 */
298 private static function quoteBefore($text, $at, $context)
299 {
300 // A character is at most four bytes, so the window always holds enough.
301 $slice = substr($text, max(0, $at - (4 * $context)), min($at, 4 * $context));
302 // A slice starting mid-character would fail the /u match and empty the quote.
303 $slice = (string) preg_replace('/^[\x80-\xBF]+/', '', $slice);
304 preg_match('/.{0,' . $context . '}$/su', $slice, $match);
305
306 return $match[0] ?? '';
307 }
308
309 /**
310 * Up to $context characters of $text starting at byte offset $at.
311 *
312 * @param string $text The text a match was found in.
313 * @param integer $at Where the match ends, in bytes.
314 * @param integer $context How many characters to quote.
315 * @return string
316 */
317 private static function quoteAfter($text, $at, $context)
318 {
319 // A slice ending mid-character would fail the /u match and empty the quote.
320 $slice = (string) preg_replace(
321 '/(?:[\xC2-\xDF]|[\xE0-\xEF][\x80-\xBF]?|[\xF0-\xF4][\x80-\xBF]{0,2})$/D',
322 '',
323 substr($text, $at, 4 * $context)
324 );
325 preg_match('/^.{0,' . $context . '}/su', $slice, $match);
326
327 return $match[0] ?? '';
328 }
329
330 /**
331 * @param integer $id A post id.
332 * @return \WP_Post|null
333 */
334 private static function byId($id)
335 {
336 $post = get_post($id);
337 if (!$post || in_array($post->post_status, ['auto-draft', 'trash'], true)) {
338 return null;
339 }
340
341 return $post;
342 }
343
344 /**
345 * @param string $slug A post slug.
346 * @return \WP_Post|null
347 */
348 private static function bySlug($slug)
349 {
350 $posts = get_posts([
351 'name' => $slug,
352 'post_type' => self::POST_TYPES,
353 'post_status' => 'any',
354 'posts_per_page' => 1,
355 ]);
356
357 return $posts ? $posts[0] : null;
358 }
359
360 /**
361 * @param \WP_Post $post The post being read.
362 * @param string $format Which of self::FORMATS to return.
363 * @return string
364 */
365 private static function content($post, $format)
366 {
367 if ($format === 'blocks') {
368 return $post->post_content;
369 }
370
371 if ($format === 'excerpt') {
372 return get_the_excerpt($post);
373 }
374
375 return self::rendered($post);
376 }
377
378 /**
379 * Keys under an underscore are WordPress and plugin bookkeeping.
380 *
381 * @param integer $id The post to read meta from.
382 * @return array Name => value pairs.
383 */
384 private static function meta($id)
385 {
386 $meta = [];
387 foreach (get_post_meta($id) as $key => $stored) {
388 if (strpos($key, '_') === 0 || count($meta) >= self::MAX_META_KEYS) {
389 continue;
390 }
391
392 $value = maybe_unserialize(isset($stored[0]) ? $stored[0] : '');
393 $meta[$key] = is_string($value) && mb_strlen($value) > self::MAX_META_VALUE
394 ? mb_substr($value, 0, self::MAX_META_VALUE)
395 : $value;
396 }
397
398 return $meta;
399 }
400 }
401