PluginProbe
Friends / 4.2.1
Friends v4.2.1
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.2.1, at includes/class-rest.php

607 lines 18.2 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 'extension',
110 array(
111 'methods' => array( 'GET', 'POST' ),
112 'callback' => array( $this, 'rest_extension' ),
113 'permission_callback' => '__return_true', // Public.
114 'params' => array(
115 'key' => array(
116 'type' => 'string',
117 'required' => false,
118 ),
119 ),
120 )
121 );
122
123 register_rest_route(
124 self::PREFIX,
125 'extension/action',
126 array(
127 'methods' => 'POST',
128 'callback' => array( $this, 'rest_extension_action' ),
129 'permission_callback' => array( $this, 'browser_extension_action_permission_callback' ),
130 'args' => array(
131 'action' => array(
132 'type' => 'string',
133 'required' => true,
134 ),
135 'key' => array(
136 'type' => 'string',
137 'required' => true,
138 ),
139 ),
140 )
141 );
142 }
143
144 /**
145 * Translate a REST error message
146 *
147 * @param string $message The message to translate.
148 * @return string The translated message.
149 */
150 public static function translate_error_message( $message ) {
151 $messages = self::get_error_messages( true );
152 if ( isset( $messages[ $message ] ) ) {
153 return $messages[ $message ];
154 }
155 return $message;
156 }
157
158 /**
159 * Get the error messages for REST
160 *
161 * @return array The error messages.
162 */
163 public static function get_error_messages() {
164 $english = function () {
165 return 'en_US';
166 };
167
168 // In the first pass never translate these messages.
169 add_filter( 'locale', $english );
170
171 $messages = array(
172 'friends_invalid_parameters' => __( 'Not all necessary parameters were provided.', 'friends' ),
173 'friends_invalid_url' => __( 'An invalid URL was provided.', 'friends' ),
174 'friends_no_request' => __( 'No request was found.', 'friends' ),
175 'friends_invalid_site' => __( 'An invalid site was provided.', 'friends' ),
176 'unknown' => __( 'An unknown error occurred.', 'friends' ),
177 );
178
179 remove_filter( 'locale', $english );
180
181 // Add mapping for English text to translations.
182 foreach ( $messages as $key => $message ) {
183 $messages[ $message ] = __( $message, 'friends' ); // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
184 }
185
186 return $messages;
187 }
188
189 /**
190 * Standardize the error message texts
191 *
192 * @param string $code The error code.
193 * @param string $message The message to return, if not provided the default message will be used.
194 * @param int $status The status code to return.
195 *
196 * @return \WP_Error The error object.
197 */
198 public static function error( $code, $message = '', $status = 403 ) {
199 if ( ! $message ) {
200 // Return English error messages.
201 $messages = self::get_error_messages();
202 if ( isset( $messages[ $code ] ) ) {
203 $message = $messages[ $code ];
204 } else {
205 $message = $messages['unknown'];
206 }
207 }
208
209 return new \WP_Error(
210 $code,
211 $message,
212 array(
213 'status' => $status,
214 )
215 );
216 }
217
218 public function rest_embed_friend_post( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
219 // phpcs:disable WordPress.Security.NonceVerification.Recommended
220 if ( empty( $_GET['url'] ) ) {
221 return false;
222 }
223 $post_id = $this->friends->feed->url_to_postid( sanitize_text_field( wp_unslash( $_GET['url'] ) ) );
224 if ( empty( $post_id ) ) {
225 return false;
226 }
227 // phpcs:enable WordPress.Security.NonceVerification.Recommended
228
229 if ( ! in_array( get_post_type( $post_id ), apply_filters( 'friends_frontend_post_types', array() ) ) ) {
230 return false;
231 }
232
233 enqueue_embed_scripts();
234 $post = get_post( $post_id );
235 $args = compact( 'post' );
236 setup_postdata( $post );
237
238 header( 'Content-type: text/html' );
239 Friends::template_loader()->get_template_part( 'embed/header-embed', null, $args );
240 Friends::template_loader()->get_template_part( 'embed/embed-content', null, $args );
241 exit;
242 }
243
244 public function rest_get_feeds( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
245 $feeds = User_Feed::get_all_due( true );
246 $feeds = array_map(
247 function ( $feed ) {
248 return array(
249 'id' => $feed->get_id(),
250 'url' => $feed->get_url(),
251 'parser' => $feed->get_parser(),
252 'last_log' => $feed->get_last_log(),
253 'next_poll' => $feed->get_next_poll(),
254 );
255 },
256 $feeds
257 );
258
259 return $feeds;
260 }
261
262 public function rest_refresh_feed( $request ) {
263 $feed_id = $request->get_param( 'id' );
264 $feed = new User_Feed( get_term( intval( $feed_id ) ) );
265 add_filter( 'notify_about_new_friend_post', '__return_false', 999 );
266 add_action(
267 'wp_feed_options',
268 function ( &$feed ) {
269 $feed->enable_cache( false );
270 }
271 );
272 $new_posts = array();
273
274 $friend_user = $feed->get_friend_user();
275 $was_polled = false;
276 if ( $friend_user && $feed->can_be_polled_now() ) {
277 $feed->set_polling_now();
278 $new_posts = $this->friends->feed->retrieve_feed( $feed );
279 $feed->was_polled();
280 if ( is_wp_error( $new_posts ) ) {
281 return $new_posts;
282 }
283 $was_polled = true;
284 $friend_user->delete_outdated_posts();
285 }
286
287 return array(
288 'new_posts' => count( $new_posts ),
289 'url' => $feed->get_url(),
290 'was_polled' => $was_polled,
291 );
292 }
293
294 public function rest_extension( $request ) {
295 $return = array(
296 'version' => Friends::VERSION,
297 'friends_url' => home_url( '/friends/' ),
298 'settings_url' => admin_url( 'admin.php?page=friends-browser-extension' ),
299 );
300
301 if ( 'POST' === $request->get_method() && $request->get_param( 'key' ) ) {
302 $current_user = self::get_browser_extension_user( $request->get_param( 'key' ) );
303 if ( ! is_wp_error( $current_user ) ) {
304 $context = $this->get_browser_extension_request_context( $request, $current_user );
305
306 /**
307 * Allows plugins to register actions for the Friends browser extension.
308 *
309 * Each action is an associative array with:
310 * - `name` (string, required) — label shown in the extension popup.
311 * - `url` (string, required) — target URL; may contain `{current_url}` which the extension substitutes with the current page URL (URL-encoded).
312 * - `method` (string, optional) — if `"POST"`, the extension submits a form instead of opening a link.
313 * - `fields` (object, optional) — for POST actions, key/value pairs of form fields; values may contain `{current_url}` (raw) and `{page_html}` placeholders.
314 * - `run` (string, optional) — if `"inline"`, the extension handles the response in place instead of opening a new tab.
315 * - `inputs` (array, optional) — user-editable fields for inline actions.
316 * - `category` (string, optional) — groups actions under a named header; actions without a category appear under the default "Actions" header.
317 *
318 * Example:
319 * ```php
320 * add_filter( 'friends_browser_extension_actions', function ( $actions, $current_user, $context ) {
321 * $actions[] = array(
322 * 'name' => 'Save to Collection',
323 * 'url' => home_url( '/collect/?url={current_url}' ),
324 * );
325 * return $actions;
326 * }, 10, 3 );
327 * ```
328 *
329 * @param array $actions The array of actions.
330 * @param \WP_User $current_user The current user.
331 * @param array $context Browser extension request context: key, version, user, and request.
332 * @return array The modified array of actions.
333 */
334 $previous_user_id = get_current_user_id();
335 wp_set_current_user( $current_user->ID );
336 try {
337 $actions = apply_filters( 'friends_browser_extension_actions', array(), $current_user, $context );
338 } finally {
339 wp_set_current_user( $previous_user_id );
340 }
341
342 $return['actions'] = array_values(
343 array_filter(
344 $actions,
345 function ( $action ) {
346 return is_array( $action )
347 && ! empty( $action['name'] )
348 && is_string( $action['name'] )
349 && ! empty( $action['url'] )
350 && is_string( $action['url'] );
351 }
352 )
353 );
354 } else {
355 $return['error'] = 'Invalid API key';
356 }
357 }
358
359 return $return;
360 }
361
362 /**
363 * Validate a browser extension inline action request.
364 *
365 * @param \WP_REST_Request $request The REST request.
366 * @return true|\WP_Error True if the request is allowed, otherwise an error.
367 */
368 public function browser_extension_action_permission_callback( $request ) {
369 $current_user = self::get_browser_extension_user( $request->get_param( 'key' ) );
370 if ( is_wp_error( $current_user ) ) {
371 return new \WP_Error(
372 $current_user->get_error_code(),
373 $current_user->get_error_message(),
374 array( 'status' => 401 )
375 );
376 }
377
378 $attributes = $request->get_attributes();
379 $attributes['friends_browser_extension_user'] = $current_user;
380 $attributes['friends_browser_extension_context'] = $this->get_browser_extension_request_context( $request, $current_user );
381 $request->set_attributes( $attributes );
382
383 return true;
384 }
385
386 /**
387 * Handle a browser extension inline action.
388 *
389 * @param \WP_REST_Request $request The REST request.
390 * @return \WP_REST_Response The REST response.
391 */
392 public function rest_extension_action( $request ) {
393 $url_params = $request->get_url_params();
394 $action = isset( $url_params['action'] ) ? $url_params['action'] : $request->get_param( 'action' );
395 $action = sanitize_key( (string) wp_unslash( $action ) );
396 if ( ! $action ) {
397 return self::browser_extension_action_error(
398 new \WP_Error( 'friends_missing_browser_extension_action', __( 'No browser extension action was provided.', 'friends' ) ),
399 400
400 );
401 }
402
403 $attributes = $request->get_attributes();
404 $current_user = isset( $attributes['friends_browser_extension_user'] ) ? $attributes['friends_browser_extension_user'] : self::get_browser_extension_user( $request->get_param( 'key' ) );
405 if ( is_wp_error( $current_user ) ) {
406 return self::browser_extension_action_error( $current_user, 401 );
407 }
408
409 $context = isset( $attributes['friends_browser_extension_context'] ) ? $attributes['friends_browser_extension_context'] : $this->get_browser_extension_request_context( $request, $current_user );
410 $context['action'] = $action;
411
412 $previous_user_id = get_current_user_id();
413 wp_set_current_user( $current_user->ID );
414
415 try {
416 /**
417 * Handles a browser extension inline action.
418 *
419 * Return a \WP_REST_Response, \WP_Error, array, or scalar value. Returning null means the action
420 * was not handled.
421 *
422 * @param mixed $response The action response.
423 * @param string $action The browser extension action name.
424 * @param \WP_REST_Request $request The REST request.
425 * @param \WP_User $current_user The user authenticated by the browser extension key.
426 * @param array $context Browser extension request context.
427 */
428 $response = apply_filters( 'friends_browser_extension_action', null, $action, $request, $current_user, $context );
429
430 /**
431 * Handles a specific browser extension inline action.
432 *
433 * The dynamic portion of the hook name, `$action`, is the sanitized action name from the
434 * request's `action` parameter.
435 *
436 * @param mixed $response The action response.
437 * @param \WP_REST_Request $request The REST request.
438 * @param \WP_User $current_user The user authenticated by the browser extension key.
439 * @param array $context Browser extension request context.
440 */
441 $response = apply_filters( "friends_browser_extension_action_{$action}", $response, $request, $current_user, $context );
442 } finally {
443 wp_set_current_user( $previous_user_id );
444 }
445
446 if ( null === $response ) {
447 return self::browser_extension_action_error(
448 new \WP_Error( 'friends_unknown_browser_extension_action', __( 'Unknown browser extension action.', 'friends' ) ),
449 404
450 );
451 }
452
453 return self::prepare_browser_extension_action_response( $response );
454 }
455
456 /**
457 * Get the user authenticated by a browser extension key.
458 *
459 * @param string $key The browser extension API key.
460 * @return \WP_User|\WP_Error The authenticated user or an error.
461 */
462 private static function get_browser_extension_user( $key ) {
463 $key = sanitize_text_field( (string) wp_unslash( $key ) );
464 if ( ! $key ) {
465 return new \WP_Error( 'friends_invalid_browser_extension_key', __( 'Invalid API key', 'friends' ) );
466 }
467
468 $user = Admin::get_browser_api_key_user( $key );
469 if ( ! $user ) {
470 return new \WP_Error( 'friends_invalid_browser_extension_key', __( 'Invalid API key', 'friends' ) );
471 }
472
473 return $user;
474 }
475
476 /**
477 * Build browser extension request context for plugin filters.
478 *
479 * @param \WP_REST_Request $request The REST request.
480 * @param \WP_User $current_user The user authenticated by the browser extension key.
481 * @return array Browser extension request context.
482 */
483 private function get_browser_extension_request_context( $request, $current_user ) {
484 $key = sanitize_text_field( (string) wp_unslash( $request->get_param( 'key' ) ) );
485 $version = sanitize_text_field( (string) wp_unslash( $request->get_param( 'version' ) ) );
486
487 if ( ! $version ) {
488 $version = sanitize_text_field( (string) wp_unslash( $request->get_param( 'extension_version' ) ) );
489 }
490
491 return array(
492 'key' => $key,
493 'browser_extension_key' => $key,
494 'version' => $version,
495 'extension_version' => $version,
496 'user' => $current_user,
497 'request' => $request,
498 );
499 }
500
501 /**
502 * Prepare a browser extension action response.
503 *
504 * @param mixed $response The handler response.
505 * @return \WP_REST_Response The REST response.
506 */
507 private static function prepare_browser_extension_action_response( $response ) {
508 if ( is_wp_error( $response ) ) {
509 return self::browser_extension_action_error( $response, 400 );
510 }
511
512 if ( $response instanceof \WP_REST_Response ) {
513 return $response;
514 }
515
516 if ( true === $response ) {
517 $response = array(
518 'success' => true,
519 );
520 }
521
522 return rest_ensure_response( $response );
523 }
524
525 /**
526 * Format a browser extension action error response.
527 *
528 * @param \WP_Error $error The error.
529 * @param int $status The default HTTP status.
530 * @return \WP_REST_Response The REST response.
531 */
532 private static function browser_extension_action_error( \WP_Error $error, $status ) {
533 $error_data = $error->get_error_data();
534 if ( is_array( $error_data ) && ! empty( $error_data['status'] ) ) {
535 $status = absint( $error_data['status'] );
536 }
537
538 return new \WP_REST_Response(
539 array(
540 'success' => false,
541 'code' => $error->get_error_code(),
542 'message' => $error->get_error_message(),
543 ),
544 $status
545 );
546 }
547
548 /**
549 * Discover the REST URL for a friend site
550 *
551 * @param array $feeds The URL of the site.
552 * @return string|\WP_Error The REST URL or an error.
553 */
554 public function get_friends_rest_url( $feeds ) {
555 foreach ( $feeds as $feed_url => $feed ) {
556 if ( isset( $feed['parser'] ) && 'friends' === $feed['parser'] ) {
557 return $feed_url;
558 }
559 }
560
561 return false;
562 }
563
564 /**
565 * Discover the REST URL for a friend site
566 *
567 * @param string $url The URL of the site.
568 * @return string|\WP_Error The REST URL or an error.
569 */
570 public function discover_rest_url( $url ) {
571 if ( ! is_string( $url ) || ! Friends::check_url( $url ) ) {
572 return self::error( 'friends_invalid_url' );
573 }
574
575 $response = wp_safe_remote_get(
576 $url,
577 array(
578 'timeout' => 20,
579 'redirection' => 5,
580 )
581 );
582
583 if ( is_wp_error( $response ) ) {
584 return $response;
585 }
586
587 if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
588 $dom = new \DOMDocument();
589 set_error_handler( '__return_null' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
590 $dom->loadHTML( wp_remote_retrieve_body( $response ) );
591 restore_error_handler();
592
593 $xpath = new \DOMXpath( $dom );
594 foreach ( $xpath->query( '//link[@rel and @href]' ) as $link ) {
595 if ( 'friends-base-url' === $link->getAttribute( 'rel' ) ) {
596 $rest_url = $link->getAttribute( 'href' );
597 if ( is_string( $rest_url ) && Friends::check_url( $rest_url ) ) {
598 return $rest_url;
599 }
600 }
601 }
602 }
603
604 return null;
605 }
606 }
607