` */ public static function sign( $secret, $timestamp, $method, $path, $body ) { $string_to_sign = $timestamp . "\n" . strtoupper( $method ) . "\n" . $path . "\n" . $body; return 'v1=' . hash_hmac( 'sha256', $string_to_sign, (string) $secret ); } /** * Send one signed request. * * @param string $method GET, POST or DELETE. * @param string $path e.g. `/wordpress/forms`. * @param array|null $data JSON body, or null for none. * @param int $timeout Seconds. * @param array{site_id: string, secret: string}|null $credentials Other credentials than the current * connection's (removing an older connection). * @return Result */ public static function signed( $method, $path, $data = null, $timeout = self::ADMIN_TIMEOUT, $credentials = null ) { $current = null === $credentials; if ( $current ) { $credentials = self::credentials_for_sending(); } if ( ! is_array( $credentials ) || empty( $credentials['site_id'] ) || empty( $credentials['secret'] ) ) { return Result::local( 'not_connected' ); } $body = ''; if ( null !== $data ) { $body = wp_json_encode( $data, self::JSON_FLAGS ); if ( ! is_string( $body ) ) { return Result::local( 'encoding' ); } } $timestamp = (string) time(); $method = strtoupper( $method ); $result = self::send( $method, $path, $body, array( 'X-LaunchList-Site' => (string) $credentials['site_id'], 'X-LaunchList-Timestamp' => $timestamp, 'X-LaunchList-Signature' => self::sign( $credentials['secret'], $timestamp, $method, $path, $body ), ), $timeout ); if ( $current ) { self::observe( $result, $credentials ); } return $result; } /** * Send one unsigned JSON POST (only the token exchange, which is what creates the secret). * * @param string $path e.g. `/wordpress/token`. * @param array $data JSON body. * @param int $timeout Seconds. * @return Result */ public static function post( $path, array $data, $timeout = self::ADMIN_TIMEOUT ) { $body = wp_json_encode( $data, self::JSON_FLAGS ); if ( ! is_string( $body ) ) { return Result::local( 'encoding' ); } return self::send( 'POST', $path, $body, array(), $timeout ); } /** * Send one submission of one of the site's own forms. * * @param array $payload The body PROTOCOL.md §3 describes. * @return Result */ public static function submit( array $payload ) { // Always JSON objects, even when every key happens to be a number ("0", "1", ...). foreach ( array( 'data', 'attribution' ) as $key ) { if ( isset( $payload[ $key ] ) && is_array( $payload[ $key ] ) ) { $payload[ $key ] = (object) $payload[ $key ]; } } return self::signed( 'POST', '/wordpress/submissions', $payload, self::RELAY_TIMEOUT ); } /** * Make the request. * * @param string $method HTTP method. * @param string $path Path with a leading slash. * @param string $body Body bytes, `""` for none. * @param array $headers Extra headers. * @param int $timeout Seconds. * @return Result */ private static function send( $method, $path, $body, array $headers, $timeout ) { $origin = Connection::origin(); if ( '' === $origin ) { return Result::local( 'invalid_origin' ); } if ( 0 !== strpos( $path, '/' ) || false !== strpos( $path, '?' ) ) { return Result::local( 'invalid_path' ); } $headers['Accept'] = 'application/json'; $headers['X-LaunchList-Plugin'] = LAUNCHLIST_VERSION; $args = array( 'method' => $method, 'timeout' => (int) $timeout, 'redirection' => 0, 'user-agent' => 'LaunchList-WordPress/' . LAUNCHLIST_VERSION, 'headers' => $headers, ); if ( '' !== $body ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = $body; } return Result::from_response( wp_remote_request( $origin . $path, $args ) ); } /** * Learn from an answer to a request signed with the connection's credentials: a 401 that says * the site or its secret is gone marks the connection revoked, a `stale` one records clock skew, * and any accepted request clears a recorded skew. Each change applies only while the credentials * the request was signed with are still the stored ones (a reconnect may have happened meanwhile). * * @param Result $result The answer. * @param array $credentials The credentials the request was signed with. * @return void */ private static function observe( Result $result, array $credentials ) { if ( $result->revoked() ) { Connection::mark_revoked( $result->reason, $credentials ); return; } if ( 401 === $result->status && 'stale' === $result->reason ) { Connection::record_clock_skew( $result->server_time, $credentials ); return; } if ( 401 === $result->status && 'unsigned' === $result->reason && defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'LaunchList: the server says a request arrived unsigned.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } if ( $result->ok() ) { Connection::clear_clock_skew( $credentials ); } } /** * The credentials a signed request will use: the connection's, or none on a copy of the site. * * @return array{site_id: string, secret: string, home: string}|null */ public static function credentials_for_sending() { return Connection::is_copy() ? null : Connection::credentials(); } }