Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 95 additions & 24 deletions api.wordpress.org/public_html/events/1.0/index.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
<?php
/**
* WordPress.org Events API endpoint: upcoming events near a given location.
*
* This is a standalone, unauthenticated, stateless API endpoint: WordPress is not loaded,
* so request data is never slashed, and there is no session or nonce infrastructure.
*
* phpcs:disable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
*
* @package WordPressdotorg\API\Events
*/

namespace Dotorg\API\Events;
use stdClass;

Expand Down Expand Up @@ -127,32 +138,71 @@ function parse_request() {
$location_args = array( 'restrict_by_country' => false );

// If a precise location is known, use a GET request. The values here should come from the `location` key of the result of a POST request.
if ( isset( $_GET['latitude'], $_GET['longitude'] ) ) {
$location_args['latitude'] = $_GET['latitude'];
if (
isset( $_GET['latitude'], $_GET['longitude'] ) &&
is_numeric( $_GET['latitude'] ) && is_numeric( $_GET['longitude'] )
) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- is_numeric()-checked above; kept as strings because core compares the echoed values with ===.
$location_args['latitude'] = $_GET['latitude'];
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- is_numeric()-checked above; kept as strings because core compares the echoed values with ===.
$location_args['longitude'] = $_GET['longitude'];
}

if ( isset( $_GET['country'] ) ) {
$location_args['country'] = $_GET['country'];
if ( isset( $_GET['country'] ) && '' !== $_GET['country'] ) {
// An ISO 3166-1 alpha-2 country code, which is what events are stored with.
$country = filter_var(
$_GET['country'],
FILTER_VALIDATE_REGEXP,
array( 'options' => array( 'regexp' => '/^[a-z]{2}\z/i' ) )
);

if ( false === $country ) {
send_bad_request( 'country must be an ISO 3166-1 alpha-2 country code.' );
}

$location_args['country'] = $country;
$location_args['restrict_by_country'] = true;
}

// If a precise location is not known, create a POST request with a bunch of data which can be used to determine a precise location for future GET requests.
if ( isset( $_POST['location_data'] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Scalar-checked in validate_request(); DB access is prepared, output is JSON-encoded.
$location_args = $_POST['location_data'];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

// Simplified parameters for lookup by location (city) name, with optional timezone and locale params for extra context.
if ( isset( $_REQUEST['location'] ) ) {
$location_args['location_name'] = trim( $_REQUEST['location'] );
$location_args['location_name'] = trim(
filter_var( $_REQUEST['location'], FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW )
);
}

if ( isset( $_REQUEST['timezone'] ) ) {
$location_args['timezone'] = $_REQUEST['timezone'];
// An IANA timezone identifier, e.g. `America/New_York` or `Etc/GMT+5`.
$location_args['timezone'] = filter_var(
$_REQUEST['timezone'],
FILTER_VALIDATE_REGEXP,
array(
'options' => array(
'regexp' => '#^[A-Za-z0-9/_+-]{1,50}\z#',
'default' => '',
),
)
);
}

if ( isset( $_REQUEST['locale'] ) ) {
$location_args['locale'] = $_REQUEST['locale'];
// A locale identifier, e.g. `en_US` or `pt_PT_ao90`.
$location_args['locale'] = filter_var(
$_REQUEST['locale'],
FILTER_VALIDATE_REGEXP,
array(
'options' => array(
'regexp' => '/^[A-Za-z0-9_-]{2,20}\z/',
'default' => '',
),
)
);
}

if ( isset( $_REQUEST['ip'] ) ) {
Expand All @@ -167,7 +217,7 @@ function parse_request() {
FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);

$location_args['ip'] = $public_ip ? $public_ip : $_SERVER['REMOTE_ADDR'];
$location_args['ip'] = $public_ip ? $public_ip : (string) filter_var( $_SERVER['REMOTE_ADDR'] ?? '', FILTER_VALIDATE_IP );
}

return $location_args;
Expand All @@ -192,19 +242,33 @@ function validate_request() {
'ip',
];

$get_only = array( 'latitude', 'longitude', 'country' );

foreach ( $must_be_strings as $field ) {
if ( isset( $_GET[ $field ] ) && ! is_scalar( $_GET[ $field ] ) ) {
// Check the superglobal that `parse_request()` reads the field from.
$source = in_array( $field, $get_only, true ) ? $_GET : $_REQUEST;

if ( isset( $source[ $field ] ) && ! is_scalar( $source[ $field ] ) ) {
send_bad_request( $field . ' must be of type string.' );
}
}

if ( isset( $_POST['location_data'] ) && ! is_array( $_POST['location_data'] ) ) {
send_bad_request( 'location_data must be an array.' );
}

if ( ! empty( $_POST['location_data'] ) ) {
// phpcs:ignore WordPress.Security -- Public unauthenticated endpoint; the value is only type-checked here, never used or output.
foreach ( $_POST['location_data'] as $value ) {
// phpcs:ignore WordPress.Security -- Public unauthenticated endpoint; the values are only validated here, never used or output.
foreach ( $_POST['location_data'] as $key => $value ) {
if ( ! is_scalar( $value ) ) {
// The key is omitted from the message because it is unsanitized request input.
send_bad_request( 'location_data values must be of type string.' );
}

// An invalid code would otherwise skip the country filter in `get_events()`.
if ( 'country' === $key && '' !== (string) $value && ! preg_match( '/^[a-z]{2}\z/i', (string) $value ) ) {
send_bad_request( 'country must be an ISO 3166-1 alpha-2 country code.' );
}
}
}
}
Expand All @@ -215,8 +279,8 @@ function validate_request() {
* The `Content-Type` is set explicitly, because the header that `send_response()` sets is only
* reached on the success path, and PHP would otherwise default this body to `text/html`.
*
* `wp_json_encode()` is intentionally not used here; it is loaded by `bootstrap()`, which runs
* after the request is validated.
* `wp_json_encode()` is intentionally not used here; it is loaded by `bootstrap()`, which has
* not run yet when `validate_request()` rejects a request.
*
* @param string $reason The reason the request was rejected. Must not contain any unescaped
* request input.
Expand Down Expand Up @@ -257,14 +321,17 @@ function build_response( $location, $location_args ) {
$error = 'temp-request-throttled';
}

// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for prefix/substring comparisons, never output or stored.
$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? '';

if ( $location ) {
$event_args = array(
'is_client_core' => is_client_core( $_SERVER['HTTP_USER_AGENT'] ),
'is_client_core' => is_client_core( $user_agent ),
'restrict_by_country' => $location_args['restrict_by_country'],
);

if ( isset( $_REQUEST['number'] ) ) {
$event_args['number'] = $_REQUEST['number'];
if ( isset( $_REQUEST['number'] ) && is_numeric( $_REQUEST['number'] ) ) {
$event_args['number'] = (int) $_REQUEST['number'];
}

if ( ! empty( $location['latitude'] ) ) {
Expand All @@ -290,13 +357,13 @@ function build_response( $location, $location_args ) {
$events = maybe_add_regional_wordcamps(
$events,
get_regional_wordcamp_data(),
$_SERVER['HTTP_USER_AGENT'],
$user_agent,
time(),
$location
);

$events = pin_next_online_wordcamp( $events, $_SERVER['HTTP_USER_AGENT'], time(), $location['country'] ?? '' );
$events = pin_next_workshop_discussion_group( $events, $_SERVER['HTTP_USER_AGENT'] );
$events = pin_next_online_wordcamp( $events, $user_agent, time(), $location['country'] ?? '' );
$events = pin_next_workshop_discussion_group( $events, $user_agent );
$events = pin_one_off_events( $events, time() );
$events = remove_duplicate_events( $events );

Expand Down Expand Up @@ -331,7 +398,12 @@ function build_response( $location, $location_args ) {
* @return bool
*/
function is_client_core( $user_agent = null ) {
return str_starts_with( $user_agent ?? $_SERVER['HTTP_USER_AGENT'], 'WordPress/' );
if ( null === $user_agent ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for a prefix comparison, never output or stored.
$user_agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
}

return str_starts_with( $user_agent, 'WordPress/' );
}

/**
Expand Down Expand Up @@ -722,7 +794,7 @@ function get_country_code_from_locale( $locale ) {
return null;
}

preg_match( '/^[a-z]+[-_]([a-z]+)$/i', $locale, $match );
preg_match( '/^[a-z]+[-_]([a-z]+)\z/i', $locale, $match );

$country_code = $match[1] ?? null;

Expand Down Expand Up @@ -800,7 +872,6 @@ function guess_location_from_country( $location_name ) {
return $country;
}


/**
* Get the country that corresponds to the given country name
*
Expand Down Expand Up @@ -910,7 +981,7 @@ function get_events( $args = array() ) {
}

// Allow queries for limiting to specific countries.
if ( $args['restrict_by_country'] && ! empty( $args['country'] ) && preg_match( '![a-z]{2}!i', $args['country'] ) ) {
if ( $args['restrict_by_country'] && ! empty( $args['country'] ) && preg_match( '/^[a-z]{2}\z/i', $args['country'] ) ) {
$wheres[] = '`country` = %s';
$sql_values[] = $args['country'];
}
Expand Down Expand Up @@ -1081,7 +1152,7 @@ function build_sticky_wordcamp_query( $request_args, $distance ) {
}

// Allow queries for limiting to specific countries.
if ( $request_args['restrict_by_country'] && ! empty( $request_args['country'] ) && preg_match( '![a-z]{2}!i', $request_args['country'] ) ) {
if ( $request_args['restrict_by_country'] && ! empty( $request_args['country'] ) && preg_match( '/^[a-z]{2}\z/i', $request_args['country'] ) ) {
$where[] = '`country` = %s';
$values[] = $request_args['country'];
}
Expand Down
Loading
Loading