PluginProbe
Friends / trunk
Friends vtrunk
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 2.9.2 All 87 releases
friends / includes / class-rest.php

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

613 lines 18.7 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 * - `id` (string, optional) — stable action identifier for clients that persist action state.
311 * - `name` (string, required) — label shown in the extension popup.
312 * - `url` (string, required) — target URL; may contain `{current_url}` which the extension substitutes with the current page URL (URL-encoded).
313 * - `method` (string, optional) — if `"POST"`, the extension submits a form instead of opening a link.
314 * - `fields` (object, optional) — for POST actions, key/value pairs of form fields; values may contain `{current_url}` (raw) and `{page_html}` placeholders.
315 * - `run` (string, optional) — if `"inline"`, the extension handles the response in place instead of opening a new tab.
316 * - `inputs` (array, optional) — user-editable fields for inline actions.
317 * - `submit_label` (string, optional) — label for the inline action submit button.
318 * - `category` (string, optional) — groups actions under a named header; actions without a category appear under the default "Actions" header.
319 *
320 * Inline action responses may include `message`, `edit_url`, and `link_label`. They may also
321 * include `fields`, `values`, and `submit_label` to let the browser extension update the same
322 * inline form for follow-up edits after the first action has created server-side state.
323 *
324 * Example:
325 * ```php
326 * add_filter( 'friends_browser_extension_actions', function ( $actions, $current_user, $context ) {
327 * $actions[] = array(
328 * 'name' => 'Save to Collection',
329 * 'url' => home_url( '/collect/?url={current_url}' ),
330 * );
331 * return $actions;
332 * }, 10, 3 );
333 * ```
334 *
335 * @param array $actions The array of actions.
336 * @param \WP_User $current_user The current user.
337 * @param array $context Browser extension request context: key, version, user, and request.
338 * @return array The modified array of actions.
339 */
340 $previous_user_id = get_current_user_id();
341 wp_set_current_user( $current_user->ID );
342 try {
343 $actions = apply_filters( 'friends_browser_extension_actions', array(), $current_user, $context );
344 } finally {
345 wp_set_current_user( $previous_user_id );
346 }
347
348 $return['actions'] = array_values(
349 array_filter(
350 $actions,
351 function ( $action ) {
352 return is_array( $action )
353 && ! empty( $action['name'] )
354 && is_string( $action['name'] )
355 && ! empty( $action['url'] )
356 && is_string( $action['url'] );
357 }
358 )
359 );
360 } else {
361 $return['error'] = 'Invalid API key';
362 }
363 }
364
365 return $return;
366 }
367
368 /**
369 * Validate a browser extension inline action request.
370 *
371 * @param \WP_REST_Request $request The REST request.
372 * @return true|\WP_Error True if the request is allowed, otherwise an error.
373 */
374 public function browser_extension_action_permission_callback( $request ) {
375 $current_user = self::get_browser_extension_user( $request->get_param( 'key' ) );
376 if ( is_wp_error( $current_user ) ) {
377 return new \WP_Error(
378 $current_user->get_error_code(),
379 $current_user->get_error_message(),
380 array( 'status' => 401 )
381 );
382 }
383
384 $attributes = $request->get_attributes();
385 $attributes['friends_browser_extension_user'] = $current_user;
386 $attributes['friends_browser_extension_context'] = $this->get_browser_extension_request_context( $request, $current_user );
387 $request->set_attributes( $attributes );
388
389 return true;
390 }
391
392 /**
393 * Handle a browser extension inline action.
394 *
395 * @param \WP_REST_Request $request The REST request.
396 * @return \WP_REST_Response The REST response.
397 */
398 public function rest_extension_action( $request ) {
399 $url_params = $request->get_url_params();
400 $action = isset( $url_params['action'] ) ? $url_params['action'] : $request->get_param( 'action' );
401 $action = sanitize_key( (string) wp_unslash( $action ) );
402 if ( ! $action ) {
403 return self::browser_extension_action_error(
404 new \WP_Error( 'friends_missing_browser_extension_action', __( 'No browser extension action was provided.', 'friends' ) ),
405 400
406 );
407 }
408
409 $attributes = $request->get_attributes();
410 $current_user = isset( $attributes['friends_browser_extension_user'] ) ? $attributes['friends_browser_extension_user'] : self::get_browser_extension_user( $request->get_param( 'key' ) );
411 if ( is_wp_error( $current_user ) ) {
412 return self::browser_extension_action_error( $current_user, 401 );
413 }
414
415 $context = isset( $attributes['friends_browser_extension_context'] ) ? $attributes['friends_browser_extension_context'] : $this->get_browser_extension_request_context( $request, $current_user );
416 $context['action'] = $action;
417
418 $previous_user_id = get_current_user_id();
419 wp_set_current_user( $current_user->ID );
420
421 try {
422 /**
423 * Handles a browser extension inline action.
424 *
425 * Return a \WP_REST_Response, \WP_Error, array, or scalar value. Returning null means the action
426 * was not handled.
427 *
428 * @param mixed $response The action response.
429 * @param string $action The browser extension action name.
430 * @param \WP_REST_Request $request The REST request.
431 * @param \WP_User $current_user The user authenticated by the browser extension key.
432 * @param array $context Browser extension request context.
433 */
434 $response = apply_filters( 'friends_browser_extension_action', null, $action, $request, $current_user, $context );
435
436 /**
437 * Handles a specific browser extension inline action.
438 *
439 * The dynamic portion of the hook name, `$action`, is the sanitized action name from the
440 * request's `action` parameter.
441 *
442 * @param mixed $response The action response.
443 * @param \WP_REST_Request $request The REST request.
444 * @param \WP_User $current_user The user authenticated by the browser extension key.
445 * @param array $context Browser extension request context.
446 */
447 $response = apply_filters( "friends_browser_extension_action_{$action}", $response, $request, $current_user, $context );
448 } finally {
449 wp_set_current_user( $previous_user_id );
450 }
451
452 if ( null === $response ) {
453 return self::browser_extension_action_error(
454 new \WP_Error( 'friends_unknown_browser_extension_action', __( 'Unknown browser extension action.', 'friends' ) ),
455 404
456 );
457 }
458
459 return self::prepare_browser_extension_action_response( $response );
460 }
461
462 /**
463 * Get the user authenticated by a browser extension key.
464 *
465 * @param string $key The browser extension API key.
466 * @return \WP_User|\WP_Error The authenticated user or an error.
467 */
468 private static function get_browser_extension_user( $key ) {
469 $key = sanitize_text_field( (string) wp_unslash( $key ) );
470 if ( ! $key ) {
471 return new \WP_Error( 'friends_invalid_browser_extension_key', __( 'Invalid API key', 'friends' ) );
472 }
473
474 $user = Admin::get_browser_api_key_user( $key );
475 if ( ! $user ) {
476 return new \WP_Error( 'friends_invalid_browser_extension_key', __( 'Invalid API key', 'friends' ) );
477 }
478
479 return $user;
480 }
481
482 /**
483 * Build browser extension request context for plugin filters.
484 *
485 * @param \WP_REST_Request $request The REST request.
486 * @param \WP_User $current_user The user authenticated by the browser extension key.
487 * @return array Browser extension request context.
488 */
489 private function get_browser_extension_request_context( $request, $current_user ) {
490 $key = sanitize_text_field( (string) wp_unslash( $request->get_param( 'key' ) ) );
491 $version = sanitize_text_field( (string) wp_unslash( $request->get_param( 'version' ) ) );
492
493 if ( ! $version ) {
494 $version = sanitize_text_field( (string) wp_unslash( $request->get_param( 'extension_version' ) ) );
495 }
496
497 return array(
498 'key' => $key,
499 'browser_extension_key' => $key,
500 'version' => $version,
501 'extension_version' => $version,
502 'user' => $current_user,
503 'request' => $request,
504 );
505 }
506
507 /**
508 * Prepare a browser extension action response.
509 *
510 * @param mixed $response The handler response.
511 * @return \WP_REST_Response The REST response.
512 */
513 private static function prepare_browser_extension_action_response( $response ) {
514 if ( is_wp_error( $response ) ) {
515 return self::browser_extension_action_error( $response, 400 );
516 }
517
518 if ( $response instanceof \WP_REST_Response ) {
519 return $response;
520 }
521
522 if ( true === $response ) {
523 $response = array(
524 'success' => true,
525 );
526 }
527
528 return rest_ensure_response( $response );
529 }
530
531 /**
532 * Format a browser extension action error response.
533 *
534 * @param \WP_Error $error The error.
535 * @param int $status The default HTTP status.
536 * @return \WP_REST_Response The REST response.
537 */
538 private static function browser_extension_action_error( \WP_Error $error, $status ) {
539 $error_data = $error->get_error_data();
540 if ( is_array( $error_data ) && ! empty( $error_data['status'] ) ) {
541 $status = absint( $error_data['status'] );
542 }
543
544 return new \WP_REST_Response(
545 array(
546 'success' => false,
547 'code' => $error->get_error_code(),
548 'message' => $error->get_error_message(),
549 ),
550 $status
551 );
552 }
553
554 /**
555 * Discover the REST URL for a friend site
556 *
557 * @param array $feeds The URL of the site.
558 * @return string|\WP_Error The REST URL or an error.
559 */
560 public function get_friends_rest_url( $feeds ) {
561 foreach ( $feeds as $feed_url => $feed ) {
562 if ( isset( $feed['parser'] ) && 'friends' === $feed['parser'] ) {
563 return $feed_url;
564 }
565 }
566
567 return false;
568 }
569
570 /**
571 * Discover the REST URL for a friend site
572 *
573 * @param string $url The URL of the site.
574 * @return string|\WP_Error The REST URL or an error.
575 */
576 public function discover_rest_url( $url ) {
577 if ( ! is_string( $url ) || ! Friends::check_url( $url ) ) {
578 return self::error( 'friends_invalid_url' );
579 }
580
581 $response = wp_safe_remote_get(
582 $url,
583 array(
584 'timeout' => 20,
585 'redirection' => 5,
586 )
587 );
588
589 if ( is_wp_error( $response ) ) {
590 return $response;
591 }
592
593 if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
594 $dom = new \DOMDocument();
595 set_error_handler( '__return_null' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
596 $dom->loadHTML( wp_remote_retrieve_body( $response ) );
597 restore_error_handler();
598
599 $xpath = new \DOMXpath( $dom );
600 foreach ( $xpath->query( '//link[@rel and @href]' ) as $link ) {
601 if ( 'friends-base-url' === $link->getAttribute( 'rel' ) ) {
602 $rest_url = $link->getAttribute( 'href' );
603 if ( is_string( $rest_url ) && Friends::check_url( $rest_url ) ) {
604 return $rest_url;
605 }
606 }
607 }
608 }
609
610 return null;
611 }
612 }
613