ru-se.com

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

class-wp-community-events.php (18458B)


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