class-wp-community-events.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. <?php
  2. /**
  3. * Administration: Community Events class.
  4. *
  5. * @package WordPress
  6. * @subpackage Administration
  7. * @since 4.8.0
  8. */
  9. /**
  10. * Class WP_Community_Events.
  11. *
  12. * A client for api.wordpress.org/events.
  13. *
  14. * @since 4.8.0
  15. */
  16. class WP_Community_Events {
  17. /**
  18. * ID for a WordPress user account.
  19. *
  20. * @since 4.8.0
  21. *
  22. * @var int
  23. */
  24. protected $user_id = 0;
  25. /**
  26. * Stores location data for the user.
  27. *
  28. * @since 4.8.0
  29. *
  30. * @var bool|array
  31. */
  32. protected $user_location = false;
  33. /**
  34. * Constructor for WP_Community_Events.
  35. *
  36. * @since 4.8.0
  37. *
  38. * @param int $user_id WP user ID.
  39. * @param bool|array $user_location Stored location data for the user.
  40. * false to pass no location;
  41. * array to pass a location {
  42. * @type string $description The name of the location
  43. * @type string $latitude The latitude in decimal degrees notation, without the degree
  44. * symbol. e.g.: 47.615200.
  45. * @type string $longitude The longitude in decimal degrees notation, without the degree
  46. * symbol. e.g.: -122.341100.
  47. * @type string $country The ISO 3166-1 alpha-2 country code. e.g.: BR
  48. * }
  49. */
  50. public function __construct( $user_id, $user_location = false ) {
  51. $this->user_id = absint( $user_id );
  52. $this->user_location = $user_location;
  53. }
  54. /**
  55. * Gets data about events near a particular location.
  56. *
  57. * Cached events will be immediately returned if the `user_location` property
  58. * is set for the current user, and cached events exist for that location.
  59. *
  60. * Otherwise, this method sends a request to the w.org Events API with location
  61. * data. The API will send back a recognized location based on the data, along
  62. * with nearby events.
  63. *
  64. * The browser's request for events is proxied with this method, rather
  65. * than having the browser make the request directly to api.wordpress.org,
  66. * because it allows results to be cached server-side and shared with other
  67. * users and sites in the network. This makes the process more efficient,
  68. * since increasing the number of visits that get cached data means users
  69. * don't have to wait as often; if the user's browser made the request
  70. * directly, it would also need to make a second request to WP in order to
  71. * pass the data for caching. Having WP make the request also introduces
  72. * the opportunity to anonymize the IP before sending it to w.org, which
  73. * mitigates possible privacy concerns.
  74. *
  75. * @since 4.8.0
  76. *
  77. * @param string $location_search Optional. City name to help determine the location.
  78. * e.g., "Seattle". Default empty string.
  79. * @param string $timezone Optional. Timezone to help determine the location.
  80. * Default empty string.
  81. * @return array|WP_Error A WP_Error on failure; an array with location and events on
  82. * success.
  83. */
  84. public function get_events( $location_search = '', $timezone = '' ) {
  85. $cached_events = $this->get_cached_events();
  86. if ( ! $location_search && $cached_events ) {
  87. return $cached_events;
  88. }
  89. // include an unmodified $wp_version
  90. include( ABSPATH . WPINC . '/version.php' );
  91. $api_url = 'http://api.wordpress.org/events/1.0/';
  92. $request_args = $this->get_request_args( $location_search, $timezone );
  93. $request_args['user-agent'] = 'WordPress/' . $wp_version . '; ' . home_url( '/' );
  94. if ( wp_http_supports( array( 'ssl' ) ) ) {
  95. $api_url = set_url_scheme( $api_url, 'https' );
  96. }
  97. $response = wp_remote_get( $api_url, $request_args );
  98. $response_code = wp_remote_retrieve_response_code( $response );
  99. $response_body = json_decode( wp_remote_retrieve_body( $response ), true );
  100. $response_error = null;
  101. if ( is_wp_error( $response ) ) {
  102. $response_error = $response;
  103. } elseif ( 200 !== $response_code ) {
  104. $response_error = new WP_Error(
  105. 'api-error',
  106. /* translators: %d: Numeric HTTP status code, e.g. 400, 403, 500, 504, etc. */
  107. sprintf( __( 'Invalid API response code (%d)' ), $response_code )
  108. );
  109. } elseif ( ! isset( $response_body['location'], $response_body['events'] ) ) {
  110. $response_error = new WP_Error(
  111. 'api-invalid-response',
  112. isset( $response_body['error'] ) ? $response_body['error'] : __( 'Unknown API error.' )
  113. );
  114. }
  115. if ( is_wp_error( $response_error ) ) {
  116. return $response_error;
  117. } else {
  118. $expiration = false;
  119. if ( isset( $response_body['ttl'] ) ) {
  120. $expiration = $response_body['ttl'];
  121. unset( $response_body['ttl'] );
  122. }
  123. /*
  124. * The IP in the response is usually the same as the one that was sent
  125. * in the request, but in some cases it is different. In those cases,
  126. * it's important to reset it back to the IP from the request.
  127. *
  128. * For example, if the IP sent in the request is private (e.g., 192.168.1.100),
  129. * then the API will ignore that and use the corresponding public IP instead,
  130. * and the public IP will get returned. If the public IP were saved, though,
  131. * then get_cached_events() would always return `false`, because the transient
  132. * would be generated based on the public IP when saving the cache, but generated
  133. * based on the private IP when retrieving the cache.
  134. */
  135. if ( ! empty( $response_body['location']['ip'] ) ) {
  136. $response_body['location']['ip'] = $request_args['body']['ip'];
  137. }
  138. /*
  139. * The API doesn't return a description for latitude/longitude requests,
  140. * but the description is already saved in the user location, so that
  141. * one can be used instead.
  142. */
  143. if ( $this->coordinates_match( $request_args['body'], $response_body['location'] ) && empty( $response_body['location']['description'] ) ) {
  144. $response_body['location']['description'] = $this->user_location['description'];
  145. }
  146. $this->cache_events( $response_body, $expiration );
  147. $response_body = $this->trim_events( $response_body );
  148. $response_body = $this->format_event_data_time( $response_body );
  149. return $response_body;
  150. }
  151. }
  152. /**
  153. * Builds an array of args to use in an HTTP request to the w.org Events API.
  154. *
  155. * @since 4.8.0
  156. *
  157. * @param string $search Optional. City search string. Default empty string.
  158. * @param string $timezone Optional. Timezone string. Default empty string.
  159. * @return array The request args.
  160. */
  161. protected function get_request_args( $search = '', $timezone = '' ) {
  162. $args = array(
  163. 'number' => 5, // Get more than three in case some get trimmed out.
  164. 'ip' => self::get_unsafe_client_ip(),
  165. );
  166. /*
  167. * Include the minimal set of necessary arguments, in order to increase the
  168. * chances of a cache-hit on the API side.
  169. */
  170. if ( empty( $search ) && isset( $this->user_location['latitude'], $this->user_location['longitude'] ) ) {
  171. $args['latitude'] = $this->user_location['latitude'];
  172. $args['longitude'] = $this->user_location['longitude'];
  173. } else {
  174. $args['locale'] = get_user_locale( $this->user_id );
  175. if ( $timezone ) {
  176. $args['timezone'] = $timezone;
  177. }
  178. if ( $search ) {
  179. $args['location'] = $search;
  180. }
  181. }
  182. // Wrap the args in an array compatible with the second parameter of `wp_remote_get()`.
  183. return array(
  184. 'body' => $args,
  185. );
  186. }
  187. /**
  188. * Determines the user's actual IP address and attempts to partially
  189. * anonymize an IP address by converting it to a network ID.
  190. *
  191. * Geolocating the network ID usually returns a similar location as the
  192. * actual IP, but provides some privacy for the user.
  193. *
  194. * $_SERVER['REMOTE_ADDR'] cannot be used in all cases, such as when the user
  195. * is making their request through a proxy, or when the web server is behind
  196. * a proxy. In those cases, $_SERVER['REMOTE_ADDR'] is set to the proxy address rather
  197. * than the user's actual address.
  198. *
  199. * Modified from https://stackoverflow.com/a/2031935/450127, MIT license.
  200. * Modified from https://github.com/geertw/php-ip-anonymizer, MIT license.
  201. *
  202. * SECURITY WARNING: This function is _NOT_ intended to be used in
  203. * circumstances where the authenticity of the IP address matters. This does
  204. * _NOT_ guarantee that the returned address is valid or accurate, and it can
  205. * be easily spoofed.
  206. *
  207. * @since 4.8.0
  208. *
  209. * @return false|string The anonymized address on success; the given address
  210. * or false on failure.
  211. */
  212. public static function get_unsafe_client_ip() {
  213. $client_ip = false;
  214. // In order of preference, with the best ones for this purpose first.
  215. $address_headers = array(
  216. 'HTTP_CLIENT_IP',
  217. 'HTTP_X_FORWARDED_FOR',
  218. 'HTTP_X_FORWARDED',
  219. 'HTTP_X_CLUSTER_CLIENT_IP',
  220. 'HTTP_FORWARDED_FOR',
  221. 'HTTP_FORWARDED',
  222. 'REMOTE_ADDR',
  223. );
  224. foreach ( $address_headers as $header ) {
  225. if ( array_key_exists( $header, $_SERVER ) ) {
  226. /*
  227. * HTTP_X_FORWARDED_FOR can contain a chain of comma-separated
  228. * addresses. The first one is the original client. It can't be
  229. * trusted for authenticity, but we don't need to for this purpose.
  230. */
  231. $address_chain = explode( ',', $_SERVER[ $header ] );
  232. $client_ip = trim( $address_chain[0] );
  233. break;
  234. }
  235. }
  236. if ( ! $client_ip ) {
  237. return false;
  238. }
  239. $anon_ip = wp_privacy_anonymize_ip( $client_ip, true );
  240. if ( '0.0.0.0' === $anon_ip || '::' === $anon_ip ) {
  241. return false;
  242. }
  243. return $anon_ip;
  244. }
  245. /**
  246. * Test if two pairs of latitude/longitude coordinates match each other.
  247. *
  248. * @since 4.8.0
  249. *
  250. * @param array $a The first pair, with indexes 'latitude' and 'longitude'.
  251. * @param array $b The second pair, with indexes 'latitude' and 'longitude'.
  252. * @return bool True if they match, false if they don't.
  253. */
  254. protected function coordinates_match( $a, $b ) {
  255. if ( ! isset( $a['latitude'], $a['longitude'], $b['latitude'], $b['longitude'] ) ) {
  256. return false;
  257. }
  258. return $a['latitude'] === $b['latitude'] && $a['longitude'] === $b['longitude'];
  259. }
  260. /**
  261. * Generates a transient key based on user location.
  262. *
  263. * This could be reduced to a one-liner in the calling functions, but it's
  264. * intentionally a separate function because it's called from multiple
  265. * functions, and having it abstracted keeps the logic consistent and DRY,
  266. * which is less prone to errors.
  267. *
  268. * @since 4.8.0
  269. *
  270. * @param array $location Should contain 'latitude' and 'longitude' indexes.
  271. * @return bool|string false on failure, or a string on success.
  272. */
  273. protected function get_events_transient_key( $location ) {
  274. $key = false;
  275. if ( isset( $location['ip'] ) ) {
  276. $key = 'community-events-' . md5( $location['ip'] );
  277. } elseif ( isset( $location['latitude'], $location['longitude'] ) ) {
  278. $key = 'community-events-' . md5( $location['latitude'] . $location['longitude'] );
  279. }
  280. return $key;
  281. }
  282. /**
  283. * Caches an array of events data from the Events API.
  284. *
  285. * @since 4.8.0
  286. *
  287. * @param array $events Response body from the API request.
  288. * @param int|bool $expiration Optional. Amount of time to cache the events. Defaults to false.
  289. * @return bool true if events were cached; false if not.
  290. */
  291. protected function cache_events( $events, $expiration = false ) {
  292. $set = false;
  293. $transient_key = $this->get_events_transient_key( $events['location'] );
  294. $cache_expiration = $expiration ? absint( $expiration ) : HOUR_IN_SECONDS * 12;
  295. if ( $transient_key ) {
  296. $set = set_site_transient( $transient_key, $events, $cache_expiration );
  297. }
  298. return $set;
  299. }
  300. /**
  301. * Gets cached events.
  302. *
  303. * @since 4.8.0
  304. *
  305. * @return false|array false on failure; an array containing `location`
  306. * and `events` items on success.
  307. */
  308. public function get_cached_events() {
  309. $cached_response = get_site_transient( $this->get_events_transient_key( $this->user_location ) );
  310. $cached_response = $this->trim_events( $cached_response );
  311. return $this->format_event_data_time( $cached_response );
  312. }
  313. /**
  314. * Adds formatted date and time items for each event in an API response.
  315. *
  316. * This has to be called after the data is pulled from the cache, because
  317. * the cached events are shared by all users. If it was called before storing
  318. * the cache, then all users would see the events in the localized data/time
  319. * of the user who triggered the cache refresh, rather than their own.
  320. *
  321. * @since 4.8.0
  322. *
  323. * @param array $response_body The response which contains the events.
  324. * @return array The response with dates and times formatted.
  325. */
  326. protected function format_event_data_time( $response_body ) {
  327. if ( isset( $response_body['events'] ) ) {
  328. foreach ( $response_body['events'] as $key => $event ) {
  329. $timestamp = strtotime( $event['date'] );
  330. /*
  331. * The `date_format` option is not used because it's important
  332. * in this context to keep the day of the week in the formatted date,
  333. * so that users can tell at a glance if the event is on a day they
  334. * are available, without having to open the link.
  335. */
  336. /* translators: Date format for upcoming events on the dashboard. Include the day of the week. See https://secure.php.net/date */
  337. $response_body['events'][ $key ]['formatted_date'] = date_i18n( __( 'l, M j, Y' ), $timestamp );
  338. $response_body['events'][ $key ]['formatted_time'] = date_i18n( get_option( 'time_format' ), $timestamp );
  339. }
  340. }
  341. return $response_body;
  342. }
  343. /**
  344. * Prepares the event list for presentation.
  345. *
  346. * Discards expired events, and makes WordCamps "sticky." Attendees need more
  347. * advanced notice about WordCamps than they do for meetups, so camps should
  348. * appear in the list sooner. If a WordCamp is coming up, the API will "stick"
  349. * it in the response, even if it wouldn't otherwise appear. When that happens,
  350. * the event will be at the end of the list, and will need to be moved into a
  351. * higher position, so that it doesn't get trimmed off.
  352. *
  353. * @since 4.8.0
  354. * @since 4.9.7 Stick a WordCamp to the final list.
  355. *
  356. * @param array $response_body The response body which contains the events.
  357. * @return array The response body with events trimmed.
  358. */
  359. protected function trim_events( $response_body ) {
  360. if ( isset( $response_body['events'] ) ) {
  361. $wordcamps = array();
  362. $today = current_time( 'Y-m-d' );
  363. foreach ( $response_body['events'] as $key => $event ) {
  364. /*
  365. * Skip WordCamps, because they might be multi-day events.
  366. * Save a copy so they can be pinned later.
  367. */
  368. if ( 'wordcamp' === $event['type'] ) {
  369. $wordcamps[] = $event;
  370. continue;
  371. }
  372. // We don't get accurate time with timezone from API, so we only take the date part (Y-m-d).
  373. $event_date = substr( $event['date'], 0, 10 );
  374. if ( $today > $event_date ) {
  375. unset( $response_body['events'][ $key ] );
  376. }
  377. }
  378. $response_body['events'] = array_slice( $response_body['events'], 0, 3 );
  379. $trimmed_event_types = wp_list_pluck( $response_body['events'], 'type' );
  380. // Make sure the soonest upcoming WordCamp is pinned in the list.
  381. if ( ! in_array( 'wordcamp', $trimmed_event_types ) && $wordcamps ) {
  382. array_pop( $response_body['events'] );
  383. array_push( $response_body['events'], $wordcamps[0] );
  384. }
  385. }
  386. return $response_body;
  387. }
  388. /**
  389. * Logs responses to Events API requests.
  390. *
  391. * @since 4.8.0
  392. * @deprecated 4.9.0 Use a plugin instead. See #41217 for an example.
  393. *
  394. * @param string $message A description of what occurred.
  395. * @param array $details Details that provide more context for the
  396. * log entry.
  397. */
  398. protected function maybe_log_events_response( $message, $details ) {
  399. _deprecated_function( __METHOD__, '4.9.0' );
  400. if ( ! WP_DEBUG_LOG ) {
  401. return;
  402. }
  403. error_log(
  404. sprintf(
  405. '%s: %s. Details: %s',
  406. __METHOD__,
  407. trim( $message, '.' ),
  408. wp_json_encode( $details )
  409. )
  410. );
  411. }
  412. }