PluginProbe
Friends / 4.3.2
Friends v4.3.2
4.3.2 4.3.1 4.3.0 4.2.2 4.2.1 4.2.0 4.1.0 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.0 2.9.1 All 88 releases
friends / includes / class-rest.php

class-rest.php in Friends 4.3.2, at includes/class-rest.php

652 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Friends REST
4 *
5 * This contains the functions for REST.
6 *
7 * @package Friends
8 */
9
10 namespace Friends;
11
12 /**
13 * This is the class for the REST part of the Friends Plugin.
14 *
15 * @since 0.6
16 *
17 * @package Friends
18 * @author Alex Kirk
19 */
20 class REST {
21 const PREFIX = 'friends/v1';
22 /**
23 * Contains a reference to the Friends class.
24 *
25 * @var Friends
26 */
27 private $friends;
28
29 /**
30 * Constructor
31 *
32 * @param Friends $friends A reference to the Friends object.
33 */
34 public function __construct( Friends $friends ) {
35 $this->friends = $friends;
36 $this->register_hooks();
37 }
38
39 /**
40 * Register the WordPress hooks
41 */
42 private function register_hooks() {
43 add_action( 'rest_api_init', array( $this, 'add_rest_routes' ) );
44 add_action( 'rest_pre_serve_request', array( $this, 'send_rest_origin' ), 20, 3 );
45 }
46
47 public function send_rest_origin( $ret, $response, $request ) {
48 if ( strpos( $request->get_route(), '/' . self::PREFIX . '/extension' ) !== 0 ) {
49 return $ret;
50 }
51
52 if ( $request->get_header( 'origin' ) ) {
53 $scheme = wp_parse_url( $request->get_header( 'origin' ), PHP_URL_SCHEME );
54 if ( 'moz-extension' === $scheme ) {
55 header( 'access-control-allow-origin: ' . $request->get_header( 'origin' ) );
56 }
57 }
58 return $ret;
59 }
60
61 /**
62 * Add the REST API to send and receive friend requests
63 */
64 public function add_rest_routes() {
65 register_rest_route(
66 self::PREFIX,
67 'embed',
68 array(
69 'methods' => 'GET',
70 'callback' => array( $this, 'rest_embed_friend_post' ),
71 'permission_callback' => function () {
72 return current_user_can( Friends::REQUIRED_ROLE );
73 },
74 )
75 );
76
77 register_rest_route(
78 self::PREFIX,
79 'get-feeds',
80 array(
81 'methods' => 'GET',
82 'callback' => array( $this, 'rest_get_feeds' ),
83 'permission_callback' => function () {
84 return current_user_can( Friends::REQUIRED_ROLE );
85 },
86 )
87 );
88
89 register_rest_route(
90 self::PREFIX,
91 'refresh-feed',
92 array(
93 'methods' => 'POST',
94 'callback' => array( $this, 'rest_refresh_feed' ),
95 'params' => array(
96 'id' => array(
97 'type' => 'integer',
98 'required' => true,
99 ),
100 ),
101 'permission_callback' => function () {
102 return current_user_can( Friends::REQUIRED_ROLE );
103 },
104 )
105 );
106
107 register_rest_route(
108 self::PREFIX,
109 'link-preview',
110 array(
111 'methods' => 'POST',
112 'callback' => array( $this, 'rest_link_preview' ),
113 'permission_callback' => function () {
114 return current_user_can( Friends::REQUIRED_ROLE );
115 },
116 'args' => array(
117 'id' => array(
118 'type' => 'integer',
119 'required' => true,
120 ),
121 ),
122 )
123 );
124
125 register_rest_route(
126 self::PREFIX,
127 'extension',
128 array(
129 'methods' => array( 'GET', 'POST' ),
130 'callback' => array( $this, 'rest_extension' ),
131 'permission_callback' => '__return_true', // Public.
132 'params' => array(
133 'key' => array(
134 'type' => 'string',
135 'required' => false,
136 ),
137 ),
138 )
139 );
140
141 register_rest_route(
142 self::PREFIX,
143 'extension/action',
144 array(
145 'methods' => 'POST',
146 'callback' => array( $this, 'rest_extension_action' ),
147 'permission_callback' => array( $this, 'browser_extension_action_permission_callback' ),
148 'args' => array(
149 'action' => array(
150 'type' => 'string',
151 'required' => true,
152 ),
153 'key' => array(
154 'type' => 'string',
155 'required' => true,
156 ),
157 ),
158 )
159 );
160 }
161
162 /**
163 * Translate a REST error message
164 *
165 * @param string $message The message to translate.
166 * @return string The translated message.
167 */
168 public static function translate_error_message( $message ) {
169 $messages = self::get_error_messages( true );
170 if ( isset( $messages[ $message ] ) ) {
171 return $messages[ $message ];
172 }
173 return $message;
174 }
175
176 /**
177 * Get the error messages for REST
178 *
179 * @return array The error messages.
180 */
181 public static function get_error_messages() {
182 $english = function () {
183 return 'en_US';
184 };
185
186 // In the first pass never translate these messages.
187 add_filter( 'locale', $english );
188
189 $messages = array(
190 'friends_invalid_parameters' => __( 'Not all necessary parameters were provided.', 'friends' ),
191 'friends_invalid_url' => __( 'An invalid URL was provided.', 'friends' ),
192 'friends_no_request' => __( 'No request was found.', 'friends' ),
193 'friends_invalid_site' => __( 'An invalid site was provided.', 'friends' ),
194 'unknown' => __( 'An unknown error occurred.', 'friends' ),
195 );
196
197 remove_filter( 'locale', $english );
198
199 // Add mapping for English text to translations.
200 foreach ( $messages as $key => $message ) {
201 $messages[ $message ] = __( $message, 'friends' ); // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
202 }
203
204 return $messages;
205 }
206
207 /**
208 * Standardize the error message texts
209 *
210 * @param string $code The error code.
211 * @param string $message The message to return, if not provided the default message will be used.
212 * @param int $status The status code to return.
213 *
214 * @return \WP_Error The error object.
215 */
216 public static function error( $code, $message = '', $status = 403 ) {
217 if ( ! $message ) {
218 // Return English error messages.
219 $messages = self::get_error_messages();
220 if ( isset( $messages[ $code ] ) ) {
221 $message = $messages[ $code ];
222 } else {
223 $message = $messages['unknown'];
224 }
225 }
226
227 return new \WP_Error(
228 $code,
229 $message,
230 array(
231 'status' => $status,
232 )
233 );
234 }
235
236 public function rest_embed_friend_post( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
237 // phpcs:disable WordPress.Security.NonceVerification.Recommended
238 if ( empty( $_GET['url'] ) ) {
239 return false;
240 }
241 $post_id = $this->friends->feed->url_to_postid( sanitize_text_field( wp_unslash( $_GET['url'] ) ) );
242 if ( empty( $post_id ) ) {
243 return false;
244 }
245 // phpcs:enable WordPress.Security.NonceVerification.Recommended
246
247 if ( ! in_array( get_post_type( $post_id ), apply_filters( 'friends_frontend_post_types', array() ) ) ) {
248 return false;
249 }
250
251 enqueue_embed_scripts();
252 $post = get_post( $post_id );
253 $args = compact( 'post' );
254 setup_postdata( $post );
255
256 header( 'Content-type: text/html' );
257 Friends::template_loader()->get_template_part( 'embed/header-embed', null, $args );
258 Friends::template_loader()->get_template_part( 'embed/embed-content', null, $args );
259 exit;
260 }
261
262 public function rest_get_feeds( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
263 $feeds = User_Feed::get_all_due( true );
264 $feeds = array_map(
265 function ( $feed ) {
266 return array(
267 'id' => $feed->get_id(),
268 'url' => $feed->get_url(),
269 'parser' => $feed->get_parser(),
270 'last_log' => $feed->get_last_log(),
271 'next_poll' => $feed->get_next_poll(),
272 );
273 },
274 $feeds
275 );
276
277 return $feeds;
278 }
279
280 public function rest_refresh_feed( $request ) {
281 $feed_id = $request->get_param( 'id' );
282 $feed = new User_Feed( get_term( intval( $feed_id ) ) );
283 add_filter( 'notify_about_new_friend_post', '__return_false', 999 );
284 add_action(
285 'wp_feed_options',
286 function ( &$feed ) {
287 $feed->enable_cache( false );
288 }
289 );
290 $new_posts = array();
291
292 $friend_user = $feed->get_friend_user();
293 $was_polled = false;
294 if ( $friend_user && $feed->can_be_polled_now() ) {
295 $feed->set_polling_now();
296 $new_posts = $this->friends->feed->retrieve_feed( $feed );
297 $feed->was_polled();
298 if ( is_wp_error( $new_posts ) ) {
299 return $new_posts;
300 }
301 $was_polled = true;
302 $friend_user->delete_outdated_posts();
303 }
304
305 return array(
306 'new_posts' => count( $new_posts ),
307 'url' => $feed->get_url(),
308 'was_polled' => $was_polled,
309 );
310 }
311
312 /**
313 * Fetch a link preview on demand.
314 *
315 * @param \WP_REST_Request $request The REST request.
316 * @return array|\WP_Error
317 */
318 public function rest_link_preview( $request ) {
319 $post_id = intval( $request->get_param( 'id' ) );
320 $post = get_post( $post_id );
321
322 if ( ! $post || ! in_array( $post->post_type, apply_filters( 'friends_frontend_post_types', array() ), true ) ) {
323 return new \WP_Error( 'friends_link_preview_invalid_post', __( 'The requested post is not available.', 'friends' ), array( 'status' => 404 ) );
324 }
325
326 $preview = Link_Preview::update_link_preview( $post_id );
327
328 return array(
329 'preview' => $preview ? $preview : false,
330 );
331 }
332
333 public function rest_extension( $request ) {
334 $return = array(
335 'version' => Friends::VERSION,
336 'friends_url' => home_url( '/friends/' ),
337 'settings_url' => admin_url( 'admin.php?page=friends-browser-extension' ),
338 );
339
340 if ( 'POST' === $request->get_method() && $request->get_param( 'key' ) ) {
341 $current_user = self::get_browser_extension_user( $request->get_param( 'key' ) );
342 if ( ! is_wp_error( $current_user ) ) {
343 $context = $this->get_browser_extension_request_context( $request, $current_user );
344
345 /**
346 * Allows plugins to register actions for the Friends browser extension.
347 *
348 * Each action is an associative array with:
349 * - `id` (string, optional) — stable action identifier for clients that persist action state.
350 * - `name` (string, required) — label shown in the extension popup.
351 * - `url` (string, required) — target URL; may contain `{current_url}` which the extension substitutes with the current page URL (URL-encoded).
352 * - `method` (string, optional) — if `"POST"`, the extension submits a form instead of opening a link.
353 * - `fields` (object, optional) — for POST actions, key/value pairs of form fields; values may contain `{current_url}` (raw) and `{page_html}` placeholders.
354 * - `run` (string, optional) — if `"inline"`, the extension handles the response in place instead of opening a new tab.
355 * - `inputs` (array, optional) — user-editable fields for inline actions.
356 * - `submit_label` (string, optional) — label for the inline action submit button.
357 * - `category` (string, optional) — groups actions under a named header; actions without a category appear under the default "Actions" header.
358 *
359 * Inline action responses may include `message`, `edit_url`, and `link_label`. They may also
360 * include `fields`, `values`, and `submit_label` to let the browser extension update the same
361 * inline form for follow-up edits after the first action has created server-side state.
362 *
363 * Example:
364 * ```php
365 * add_filter( 'friends_browser_extension_actions', function ( $actions, $current_user, $context ) {
366 * $actions[] = array(
367 * 'name' => 'Save to Collection',
368 * 'url' => home_url( '/collect/?url={current_url}' ),
369 * );
370 * return $actions;
371 * }, 10, 3 );
372 * ```
373 *
374 * @param array $actions The array of actions.
375 * @param \WP_User $current_user The current user.
376 * @param array $context Browser extension request context: key, version, user, and request.
377 * @return array The modified array of actions.
378 */
379 $previous_user_id = get_current_user_id();
380 wp_set_current_user( $current_user->ID );
381 try {
382 $actions = apply_filters( 'friends_browser_extension_actions', array(), $current_user, $context );
383 } finally {
384 wp_set_current_user( $previous_user_id );
385 }
386
387 $return['actions'] = array_values(
388 array_filter(
389 $actions,
390 function ( $action ) {
391 return is_array( $action )
392 && ! empty( $action['name'] )
393 && is_string( $action['name'] )
394 && ! empty( $action['url'] )
395 && is_string( $action['url'] );
396 }
397 )
398 );
399 } else {
400 $return['error'] = 'Invalid API key';
401 }
402 }
403
404 return $return;
405 }
406
407 /**
408 * Validate a browser extension inline action request.
409 *
410 * @param \WP_REST_Request $request The REST request.
411 * @return true|\WP_Error True if the request is allowed, otherwise an error.
412 */
413 public function browser_extension_action_permission_callback( $request ) {
414 $current_user = self::get_browser_extension_user( $request->get_param( 'key' ) );
415 if ( is_wp_error( $current_user ) ) {
416 return new \WP_Error(
417 $current_user->get_error_code(),
418 $current_user->get_error_message(),
419 array( 'status' => 401 )
420 );
421 }
422
423 $attributes = $request->get_attributes();
424 $attributes['friends_browser_extension_user'] = $current_user;
425 $attributes['friends_browser_extension_context'] = $this->get_browser_extension_request_context( $request, $current_user );
426 $request->set_attributes( $attributes );
427
428 return true;
429 }
430
431 /**
432 * Handle a browser extension inline action.
433 *
434 * @param \WP_REST_Request $request The REST request.
435 * @return \WP_REST_Response The REST response.
436 */
437 public function rest_extension_action( $request ) {
438 $url_params = $request->get_url_params();
439 $action = isset( $url_params['action'] ) ? $url_params['action'] : $request->get_param( 'action' );
440 $action = sanitize_key( (string) wp_unslash( $action ) );
441 if ( ! $action ) {
442 return self::browser_extension_action_error(
443 new \WP_Error( 'friends_missing_browser_extension_action', __( 'No browser extension action was provided.', 'friends' ) ),
444 400
445 );
446 }
447
448 $attributes = $request->get_attributes();
449 $current_user = isset( $attributes['friends_browser_extension_user'] ) ? $attributes['friends_browser_extension_user'] : self::get_browser_extension_user( $request->get_param( 'key' ) );
450 if ( is_wp_error( $current_user ) ) {
451 return self::browser_extension_action_error( $current_user, 401 );
452 }
453
454 $context = isset( $attributes['friends_browser_extension_context'] ) ? $attributes['friends_browser_extension_context'] : $this->get_browser_extension_request_context( $request, $current_user );
455 $context['action'] = $action;
456
457 $previous_user_id = get_current_user_id();
458 wp_set_current_user( $current_user->ID );
459
460 try {
461 /**
462 * Handles a browser extension inline action.
463 *
464 * Return a \WP_REST_Response, \WP_Error, array, or scalar value. Returning null means the action
465 * was not handled.
466 *
467 * @param mixed $response The action response.
468 * @param string $action The browser extension action name.
469 * @param \WP_REST_Request $request The REST request.
470 * @param \WP_User $current_user The user authenticated by the browser extension key.
471 * @param array $context Browser extension request context.
472 */
473 $response = apply_filters( 'friends_browser_extension_action', null, $action, $request, $current_user, $context );
474
475 /**
476 * Handles a specific browser extension inline action.
477 *
478 * The dynamic portion of the hook name, `$action`, is the sanitized action name from the
479 * request's `action` parameter.
480 *
481 * @param mixed $response The action response.
482 * @param \WP_REST_Request $request The REST request.
483 * @param \WP_User $current_user The user authenticated by the browser extension key.
484 * @param array $context Browser extension request context.
485 */
486 $response = apply_filters( "friends_browser_extension_action_{$action}", $response, $request, $current_user, $context );
487 } finally {
488 wp_set_current_user( $previous_user_id );
489 }
490
491 if ( null === $response ) {
492 return self::browser_extension_action_error(
493 new \WP_Error( 'friends_unknown_browser_extension_action', __( 'Unknown browser extension action.', 'friends' ) ),
494 404
495 );
496 }
497
498 return self::prepare_browser_extension_action_response( $response );
499 }
500
501 /**
502 * Get the user authenticated by a browser extension key.
503 *
504 * @param string $key The browser extension API key.
505 * @return \WP_User|\WP_Error The authenticated user or an error.
506 */
507 private static function get_browser_extension_user( $key ) {
508 $key = sanitize_text_field( (string) wp_unslash( $key ) );
509 if ( ! $key ) {
510 return new \WP_Error( 'friends_invalid_browser_extension_key', __( 'Invalid API key', 'friends' ) );
511 }
512
513 $user = Admin::get_browser_api_key_user( $key );
514 if ( ! $user ) {
515 return new \WP_Error( 'friends_invalid_browser_extension_key', __( 'Invalid API key', 'friends' ) );
516 }
517
518 return $user;
519 }
520
521 /**
522 * Build browser extension request context for plugin filters.
523 *
524 * @param \WP_REST_Request $request The REST request.
525 * @param \WP_User $current_user The user authenticated by the browser extension key.
526 * @return array Browser extension request context.
527 */
528 private function get_browser_extension_request_context( $request, $current_user ) {
529 $key = sanitize_text_field( (string) wp_unslash( $request->get_param( 'key' ) ) );
530 $version = sanitize_text_field( (string) wp_unslash( $request->get_param( 'version' ) ) );
531
532 if ( ! $version ) {
533 $version = sanitize_text_field( (string) wp_unslash( $request->get_param( 'extension_version' ) ) );
534 }
535
536 return array(
537 'key' => $key,
538 'browser_extension_key' => $key,
539 'version' => $version,
540 'extension_version' => $version,
541 'user' => $current_user,
542 'request' => $request,
543 );
544 }
545
546 /**
547 * Prepare a browser extension action response.
548 *
549 * @param mixed $response The handler response.
550 * @return \WP_REST_Response The REST response.
551 */
552 private static function prepare_browser_extension_action_response( $response ) {
553 if ( is_wp_error( $response ) ) {
554 return self::browser_extension_action_error( $response, 400 );
555 }
556
557 if ( $response instanceof \WP_REST_Response ) {
558 return $response;
559 }
560
561 if ( true === $response ) {
562 $response = array(
563 'success' => true,
564 );
565 }
566
567 return rest_ensure_response( $response );
568 }
569
570 /**
571 * Format a browser extension action error response.
572 *
573 * @param \WP_Error $error The error.
574 * @param int $status The default HTTP status.
575 * @return \WP_REST_Response The REST response.
576 */
577 private static function browser_extension_action_error( \WP_Error $error, $status ) {
578 $error_data = $error->get_error_data();
579 if ( is_array( $error_data ) && ! empty( $error_data['status'] ) ) {
580 $status = absint( $error_data['status'] );
581 }
582
583 return new \WP_REST_Response(
584 array(
585 'success' => false,
586 'code' => $error->get_error_code(),
587 'message' => $error->get_error_message(),
588 ),
589 $status
590 );
591 }
592
593 /**
594 * Discover the REST URL for a friend site
595 *
596 * @param array $feeds The URL of the site.
597 * @return string|\WP_Error The REST URL or an error.
598 */
599 public function get_friends_rest_url( $feeds ) {
600 foreach ( $feeds as $feed_url => $feed ) {
601 if ( isset( $feed['parser'] ) && 'friends' === $feed['parser'] ) {
602 return $feed_url;
603 }
604 }
605
606 return false;
607 }
608
609 /**
610 * Discover the REST URL for a friend site
611 *
612 * @param string $url The URL of the site.
613 * @return string|\WP_Error The REST URL or an error.
614 */
615 public function discover_rest_url( $url ) {
616 if ( ! is_string( $url ) || ! Friends::check_url( $url ) ) {
617 return self::error( 'friends_invalid_url' );
618 }
619
620 $response = wp_safe_remote_get(
621 $url,
622 array(
623 'timeout' => 20,
624 'redirection' => 5,
625 )
626 );
627
628 if ( is_wp_error( $response ) ) {
629 return $response;
630 }
631
632 if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
633 $dom = new \DOMDocument();
634 set_error_handler( '__return_null' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
635 $dom->loadHTML( wp_remote_retrieve_body( $response ) );
636 restore_error_handler();
637
638 $xpath = new \DOMXpath( $dom );
639 foreach ( $xpath->query( '//link[@rel and @href]' ) as $link ) {
640 if ( 'friends-base-url' === $link->getAttribute( 'rel' ) ) {
641 $rest_url = $link->getAttribute( 'href' );
642 if ( is_string( $rest_url ) && Friends::check_url( $rest_url ) ) {
643 return $rest_url;
644 }
645 }
646 }
647 }
648
649 return null;
650 }
651 }
652