wp/wp-admin/includes/class-wp-community-events.php
changeset 7 cf61fcea0001
child 9 177826044cd9
equal deleted inserted replaced
6:490d5cc509ed 7:cf61fcea0001
       
     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 bool|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 bool|array $user_location Stored location data for the user.
       
    43 	 *                                  false to pass no location;
       
    44 	 *                                  array to pass a location {
       
    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 	 *
       
    81 	 * @param string $location_search Optional. City name to help determine the location.
       
    82 	 *                                e.g., "Seattle". Default empty string.
       
    83 	 * @param string $timezone        Optional. Timezone to help determine the location.
       
    84 	 *                                Default empty string.
       
    85 	 * @return array|WP_Error A WP_Error on failure; an array with location and events on
       
    86 	 *                        success.
       
    87 	 */
       
    88 	public function get_events( $location_search = '', $timezone = '' ) {
       
    89 		$cached_events = $this->get_cached_events();
       
    90 
       
    91 		if ( ! $location_search && $cached_events ) {
       
    92 			return $cached_events;
       
    93 		}
       
    94 
       
    95 		// include an unmodified $wp_version
       
    96 		include( ABSPATH . WPINC . '/version.php' );
       
    97 
       
    98 		$api_url      = 'http://api.wordpress.org/events/1.0/';
       
    99 		$request_args = $this->get_request_args( $location_search, $timezone );
       
   100 		$request_args['user-agent'] = 'WordPress/' . $wp_version . '; ' . home_url( '/' );
       
   101 
       
   102 		if ( wp_http_supports( array( 'ssl' ) ) ) {
       
   103 			$api_url = set_url_scheme( $api_url, 'https' );
       
   104 		}
       
   105 
       
   106 		$response       = wp_remote_get( $api_url, $request_args );
       
   107 		$response_code  = wp_remote_retrieve_response_code( $response );
       
   108 		$response_body  = json_decode( wp_remote_retrieve_body( $response ), true );
       
   109 		$response_error = null;
       
   110 
       
   111 		if ( is_wp_error( $response ) ) {
       
   112 			$response_error = $response;
       
   113 		} elseif ( 200 !== $response_code ) {
       
   114 			$response_error = new WP_Error(
       
   115 				'api-error',
       
   116 				/* translators: %d: numeric HTTP status code, e.g. 400, 403, 500, 504, etc. */
       
   117 				sprintf( __( 'Invalid API response code (%d)' ), $response_code )
       
   118 			);
       
   119 		} elseif ( ! isset( $response_body['location'], $response_body['events'] ) ) {
       
   120 			$response_error = new WP_Error(
       
   121 				'api-invalid-response',
       
   122 				isset( $response_body['error'] ) ? $response_body['error'] : __( 'Unknown API error.' )
       
   123 			);
       
   124 		}
       
   125 
       
   126 		if ( is_wp_error( $response_error ) ) {
       
   127 			return $response_error;
       
   128 		} else {
       
   129 			$expiration = false;
       
   130 
       
   131 			if ( isset( $response_body['ttl'] ) ) {
       
   132 				$expiration = $response_body['ttl'];
       
   133 				unset( $response_body['ttl'] );
       
   134 			}
       
   135 
       
   136 			/*
       
   137 			 * The IP in the response is usually the same as the one that was sent
       
   138 			 * in the request, but in some cases it is different. In those cases,
       
   139 			 * it's important to reset it back to the IP from the request.
       
   140 			 *
       
   141 			 * For example, if the IP sent in the request is private (e.g., 192.168.1.100),
       
   142 			 * then the API will ignore that and use the corresponding public IP instead,
       
   143 			 * and the public IP will get returned. If the public IP were saved, though,
       
   144 			 * then get_cached_events() would always return `false`, because the transient
       
   145 			 * would be generated based on the public IP when saving the cache, but generated
       
   146 			 * based on the private IP when retrieving the cache.
       
   147 			 */
       
   148 			if ( ! empty( $response_body['location']['ip'] ) ) {
       
   149 				$response_body['location']['ip'] = $request_args['body']['ip'];
       
   150 			}
       
   151 
       
   152 			/*
       
   153 			 * The API doesn't return a description for latitude/longitude requests,
       
   154 			 * but the description is already saved in the user location, so that
       
   155 			 * one can be used instead.
       
   156 			 */
       
   157 			if ( $this->coordinates_match( $request_args['body'], $response_body['location'] ) && empty( $response_body['location']['description'] ) ) {
       
   158 				$response_body['location']['description'] = $this->user_location['description'];
       
   159 			}
       
   160 
       
   161 			$this->cache_events( $response_body, $expiration );
       
   162 
       
   163 			$response_body = $this->trim_events( $response_body );
       
   164 			$response_body = $this->format_event_data_time( $response_body );
       
   165 
       
   166 			return $response_body;
       
   167 		}
       
   168 	}
       
   169 
       
   170 	/**
       
   171 	 * Builds an array of args to use in an HTTP request to the w.org Events API.
       
   172 	 *
       
   173 	 * @since 4.8.0
       
   174 	 *
       
   175 	 * @param string $search   Optional. City search string. Default empty string.
       
   176 	 * @param string $timezone Optional. Timezone string. Default empty string.
       
   177 	 * @return array The request args.
       
   178 	 */
       
   179 	protected function get_request_args( $search = '', $timezone = '' ) {
       
   180 		$args = array(
       
   181 			'number' => 5, // Get more than three in case some get trimmed out.
       
   182 			'ip'     => self::get_unsafe_client_ip(),
       
   183 		);
       
   184 
       
   185 		/*
       
   186 		 * Include the minimal set of necessary arguments, in order to increase the
       
   187 		 * chances of a cache-hit on the API side.
       
   188 		 */
       
   189 		if ( empty( $search ) && isset( $this->user_location['latitude'], $this->user_location['longitude'] ) ) {
       
   190 			$args['latitude']  = $this->user_location['latitude'];
       
   191 			$args['longitude'] = $this->user_location['longitude'];
       
   192 		} else {
       
   193 			$args['locale'] = get_user_locale( $this->user_id );
       
   194 
       
   195 			if ( $timezone ) {
       
   196 				$args['timezone'] = $timezone;
       
   197 			}
       
   198 
       
   199 			if ( $search ) {
       
   200 				$args['location'] = $search;
       
   201 			}
       
   202 		}
       
   203 
       
   204 		// Wrap the args in an array compatible with the second parameter of `wp_remote_get()`.
       
   205 		return array(
       
   206 			'body' => $args
       
   207 		);
       
   208 	}
       
   209 
       
   210 	/**
       
   211 	 * Determines the user's actual IP address and attempts to partially
       
   212 	 * anonymize an IP address by converting it to a network ID.
       
   213 	 *
       
   214 	 * Geolocating the network ID usually returns a similar location as the
       
   215 	 * actual IP, but provides some privacy for the user.
       
   216 	 *
       
   217 	 * $_SERVER['REMOTE_ADDR'] cannot be used in all cases, such as when the user
       
   218 	 * is making their request through a proxy, or when the web server is behind
       
   219 	 * a proxy. In those cases, $_SERVER['REMOTE_ADDR'] is set to the proxy address rather
       
   220 	 * than the user's actual address.
       
   221 	 *
       
   222 	 * Modified from https://stackoverflow.com/a/2031935/450127, MIT license.
       
   223 	 * Modified from https://github.com/geertw/php-ip-anonymizer, MIT license.
       
   224 	 *
       
   225 	 * SECURITY WARNING: This function is _NOT_ intended to be used in
       
   226 	 * circumstances where the authenticity of the IP address matters. This does
       
   227 	 * _NOT_ guarantee that the returned address is valid or accurate, and it can
       
   228 	 * be easily spoofed.
       
   229 	 *
       
   230 	 * @since 4.8.0
       
   231 	 *
       
   232 	 * @return false|string The anonymized address on success; the given address
       
   233 	 *                      or false on failure.
       
   234 	 */
       
   235 	public static function get_unsafe_client_ip() {
       
   236 		$client_ip = $netmask = false;
       
   237 
       
   238 		// In order of preference, with the best ones for this purpose first.
       
   239 		$address_headers = array(
       
   240 			'HTTP_CLIENT_IP',
       
   241 			'HTTP_X_FORWARDED_FOR',
       
   242 			'HTTP_X_FORWARDED',
       
   243 			'HTTP_X_CLUSTER_CLIENT_IP',
       
   244 			'HTTP_FORWARDED_FOR',
       
   245 			'HTTP_FORWARDED',
       
   246 			'REMOTE_ADDR',
       
   247 		);
       
   248 
       
   249 		foreach ( $address_headers as $header ) {
       
   250 			if ( array_key_exists( $header, $_SERVER ) ) {
       
   251 				/*
       
   252 				 * HTTP_X_FORWARDED_FOR can contain a chain of comma-separated
       
   253 				 * addresses. The first one is the original client. It can't be
       
   254 				 * trusted for authenticity, but we don't need to for this purpose.
       
   255 				 */
       
   256 				$address_chain = explode( ',', $_SERVER[ $header ] );
       
   257 				$client_ip     = trim( $address_chain[0] );
       
   258 
       
   259 				break;
       
   260 			}
       
   261 		}
       
   262 
       
   263 		if ( ! $client_ip ) {
       
   264 			return false;
       
   265 		}
       
   266 
       
   267 		$anon_ip = wp_privacy_anonymize_ip( $client_ip, true );
       
   268 
       
   269 		if ( '0.0.0.0' === $anon_ip || '::' === $anon_ip ) {
       
   270 			return false;
       
   271 		}
       
   272 
       
   273 		return $anon_ip;
       
   274 	}
       
   275 
       
   276 	/**
       
   277 	 * Test if two pairs of latitude/longitude coordinates match each other.
       
   278 	 *
       
   279 	 * @since 4.8.0
       
   280 	 *
       
   281 	 * @param array $a The first pair, with indexes 'latitude' and 'longitude'.
       
   282 	 * @param array $b The second pair, with indexes 'latitude' and 'longitude'.
       
   283 	 * @return bool True if they match, false if they don't.
       
   284 	 */
       
   285 	protected function coordinates_match( $a, $b ) {
       
   286 		if ( ! isset( $a['latitude'], $a['longitude'], $b['latitude'], $b['longitude'] ) ) {
       
   287 			return false;
       
   288 		}
       
   289 
       
   290 		return $a['latitude'] === $b['latitude'] && $a['longitude'] === $b['longitude'];
       
   291 	}
       
   292 
       
   293 	/**
       
   294 	 * Generates a transient key based on user location.
       
   295 	 *
       
   296 	 * This could be reduced to a one-liner in the calling functions, but it's
       
   297 	 * intentionally a separate function because it's called from multiple
       
   298 	 * functions, and having it abstracted keeps the logic consistent and DRY,
       
   299 	 * which is less prone to errors.
       
   300 	 *
       
   301 	 * @since 4.8.0
       
   302 	 *
       
   303 	 * @param  array $location Should contain 'latitude' and 'longitude' indexes.
       
   304 	 * @return bool|string false on failure, or a string on success.
       
   305 	 */
       
   306 	protected function get_events_transient_key( $location ) {
       
   307 		$key = false;
       
   308 
       
   309 		if ( isset( $location['ip'] ) ) {
       
   310 			$key = 'community-events-' . md5( $location['ip'] );
       
   311 		} else if ( isset( $location['latitude'], $location['longitude'] ) ) {
       
   312 			$key = 'community-events-' . md5( $location['latitude'] . $location['longitude'] );
       
   313 		}
       
   314 
       
   315 		return $key;
       
   316 	}
       
   317 
       
   318 	/**
       
   319 	 * Caches an array of events data from the Events API.
       
   320 	 *
       
   321 	 * @since 4.8.0
       
   322 	 *
       
   323 	 * @param array    $events     Response body from the API request.
       
   324 	 * @param int|bool $expiration Optional. Amount of time to cache the events. Defaults to false.
       
   325 	 * @return bool true if events were cached; false if not.
       
   326 	 */
       
   327 	protected function cache_events( $events, $expiration = false ) {
       
   328 		$set              = false;
       
   329 		$transient_key    = $this->get_events_transient_key( $events['location'] );
       
   330 		$cache_expiration = $expiration ? absint( $expiration ) : HOUR_IN_SECONDS * 12;
       
   331 
       
   332 		if ( $transient_key ) {
       
   333 			$set = set_site_transient( $transient_key, $events, $cache_expiration );
       
   334 		}
       
   335 
       
   336 		return $set;
       
   337 	}
       
   338 
       
   339 	/**
       
   340 	 * Gets cached events.
       
   341 	 *
       
   342 	 * @since 4.8.0
       
   343 	 *
       
   344 	 * @return false|array false on failure; an array containing `location`
       
   345 	 *                     and `events` items on success.
       
   346 	 */
       
   347 	public function get_cached_events() {
       
   348 		$cached_response = get_site_transient( $this->get_events_transient_key( $this->user_location ) );
       
   349 		$cached_response = $this->trim_events( $cached_response );
       
   350 
       
   351 		return $this->format_event_data_time( $cached_response );
       
   352 	}
       
   353 
       
   354 	/**
       
   355 	 * Adds formatted date and time items for each event in an API response.
       
   356 	 *
       
   357 	 * This has to be called after the data is pulled from the cache, because
       
   358 	 * the cached events are shared by all users. If it was called before storing
       
   359 	 * the cache, then all users would see the events in the localized data/time
       
   360 	 * of the user who triggered the cache refresh, rather than their own.
       
   361 	 *
       
   362 	 * @since 4.8.0
       
   363 	 *
       
   364 	 * @param  array $response_body The response which contains the events.
       
   365 	 * @return array The response with dates and times formatted.
       
   366 	 */
       
   367 	protected function format_event_data_time( $response_body ) {
       
   368 		if ( isset( $response_body['events'] ) ) {
       
   369 			foreach ( $response_body['events'] as $key => $event ) {
       
   370 				$timestamp = strtotime( $event['date'] );
       
   371 
       
   372 				/*
       
   373 				 * The `date_format` option is not used because it's important
       
   374 				 * in this context to keep the day of the week in the formatted date,
       
   375 				 * so that users can tell at a glance if the event is on a day they
       
   376 				 * are available, without having to open the link.
       
   377 				 */
       
   378 				/* translators: Date format for upcoming events on the dashboard. Include the day of the week. See https://secure.php.net/date. */
       
   379 				$response_body['events'][ $key ]['formatted_date'] = date_i18n( __( 'l, M j, Y' ), $timestamp );
       
   380 				$response_body['events'][ $key ]['formatted_time'] = date_i18n( get_option( 'time_format' ), $timestamp );
       
   381 			}
       
   382 		}
       
   383 
       
   384 		return $response_body;
       
   385 	}
       
   386 
       
   387 	/**
       
   388 	 * Prepares the event list for presentation.
       
   389 	 *
       
   390 	 * Discards expired events, and makes WordCamps "sticky." Attendees need more
       
   391 	 * advanced notice about WordCamps than they do for meetups, so camps should
       
   392 	 * appear in the list sooner. If a WordCamp is coming up, the API will "stick"
       
   393 	 * it in the response, even if it wouldn't otherwise appear. When that happens,
       
   394 	 * the event will be at the end of the list, and will need to be moved into a
       
   395 	 * higher position, so that it doesn't get trimmed off.
       
   396 	 *
       
   397 	 * @since 4.8.0
       
   398 	 * @since 4.9.7 Stick a WordCamp to the final list.
       
   399 	 *
       
   400 	 * @param  array $response_body The response body which contains the events.
       
   401 	 * @return array The response body with events trimmed.
       
   402 	 */
       
   403 	protected function trim_events( $response_body ) {
       
   404 		if ( isset( $response_body['events'] ) ) {
       
   405 			$wordcamps         = array();
       
   406 			$current_timestamp = current_time( 'timestamp' );
       
   407 
       
   408 			foreach ( $response_body['events'] as $key => $event ) {
       
   409 				/*
       
   410 				 * Skip WordCamps, because they might be multi-day events.
       
   411 				 * Save a copy so they can be pinned later.
       
   412 				 */
       
   413 				if ( 'wordcamp' === $event['type'] ) {
       
   414 					$wordcamps[] = $event;
       
   415 					continue;
       
   416 				}
       
   417 
       
   418 				$event_timestamp = strtotime( $event['date'] );
       
   419 
       
   420 				if ( $current_timestamp > $event_timestamp && ( $current_timestamp - $event_timestamp ) > DAY_IN_SECONDS ) {
       
   421 					unset( $response_body['events'][ $key ] );
       
   422 				}
       
   423 			}
       
   424 
       
   425 			$response_body['events'] = array_slice( $response_body['events'], 0, 3 );
       
   426 			$trimmed_event_types     = wp_list_pluck( $response_body['events'], 'type' );
       
   427 
       
   428 			// Make sure the soonest upcoming WordCamps is pinned in the list.
       
   429 			if ( ! in_array( 'wordcamp', $trimmed_event_types ) && $wordcamps ) {
       
   430 				array_pop( $response_body['events'] );
       
   431 				array_push( $response_body['events'], $wordcamps[0] );
       
   432 			}
       
   433 		}
       
   434 
       
   435 		return $response_body;
       
   436 	}
       
   437 
       
   438 	/**
       
   439 	 * Logs responses to Events API requests.
       
   440 	 *
       
   441 	 * @since 4.8.0
       
   442 	 * @deprecated 4.9.0 Use a plugin instead. See #41217 for an example.
       
   443 	 *
       
   444 	 * @param string $message A description of what occurred.
       
   445 	 * @param array  $details Details that provide more context for the
       
   446 	 *                        log entry.
       
   447 	 */
       
   448 	protected function maybe_log_events_response( $message, $details ) {
       
   449 		_deprecated_function( __METHOD__, '4.9.0' );
       
   450 
       
   451 		if ( ! WP_DEBUG_LOG ) {
       
   452 			return;
       
   453 		}
       
   454 
       
   455 		error_log( sprintf(
       
   456 			'%s: %s. Details: %s',
       
   457 			__METHOD__,
       
   458 			trim( $message, '.' ),
       
   459 			wp_json_encode( $details )
       
   460 		) );
       
   461 	}
       
   462 }