'POST', 'callback' => array( self::class, 'receive' ), // The pairing value or the signature is the check (handle() below); WordPress' session has no part in it. 'permission_callback' => '__return_true', ) ); } /** * Answers a push or a check. * * @param \WP_REST_Request $request The request. */ public static function receive( \WP_REST_Request $request ): \WP_REST_Response { $pairing = static function ( ?string $id ): ?string { $value = get_transient( null === $id ? Connection::LATEST : Connection::PAIRING . $id ); return is_string( $value ) ? $value : null; }; $result = self::handle( $request->get_body(), (string) $request->get_header( 'showbits_signature' ), Options::connection(), $pairing ); if ( isset( $result['proof'] ) ) { return new \WP_REST_Response( array( 'proof' => $result['proof'] ), 200 ); } if ( 202 === $result['status'] && ! empty( $result['queued'] ) ) { if ( ! wp_next_scheduled( Plugin::CRON ) ) { wp_schedule_single_event( time(), Plugin::CRON ); } spawn_cron(); } return new \WP_REST_Response( array( 'accepted' => 202 === $result['status'] ) + ( isset( $result['reason'] ) ? array( 'reason' => $result['reason'] ) : array() ), $result['status'] ); } /** * The decision, without WordPress' request objects (the tests call it). * * @param string $body The raw body, as signed. * @param string $header ShowBits-Signature. * @param array|null $connection The stored connection. * @param callable|null $pairing The pairing value of a connect attempt by its id (null: the * latest attempt's), while one runs (string|null). * @return array{status: int, reason?: string, queued?: bool, proof?: string} */ public static function handle( string $body, string $header, ?array $connection, ?callable $pairing = null ): array { if ( strlen( $body ) > self::MAX_BODY ) { return array( 'status' => 413, 'reason' => 'size', ); } $message = json_decode( $body, true ); if ( is_array( $message ) && 'site.verify' === ( $message['type'] ?? '' ) ) { return self::verify( $message, $pairing ); } // Not (or no longer) connected: ShowBits stops trying. if ( ! $connection ) { return array( 'status' => 410, 'reason' => 'notConnected', ); } if ( ! Signature::verify( $header, (string) $connection['push_secret'], $body ) ) { return array( 'status' => 401, 'reason' => 'signature', ); } $id = is_array( $message ) ? (string) ( $message['id'] ?? '' ) : ''; $clip = is_array( $message ) ? (string) ( $message['object']['id'] ?? '' ) : ''; $ticket = is_array( $message ) ? (string) ( $message['ticket'] ?? '' ) : ''; $variant = is_array( $message ) ? ( $message['variant'] ?? null ) : null; if ( ! is_array( $message ) || 'clip.push' !== ( $message['type'] ?? '' ) || 'clip' !== ( $message['object']['type'] ?? '' ) || ! preg_match( '/^[A-Za-z0-9-]{1,64}$/', $id ) || ! preg_match( '/^[A-Za-z0-9-]{1,64}$/', $clip ) || ! preg_match( '/^[0-9a-f]{64}$/', $ticket ) || ( null !== $variant && ! in_array( $variant, Importer::QUOTE_VARIANTS, true ) ) ) { return array( 'status' => 422, 'reason' => 'invalid', ); } // Signed for another site or workspace (a reconnect elsewhere): not ours to take. if ( ( $message['site']['id'] ?? '' ) !== $connection['site_id'] || ( $message['workspace']['id'] ?? '' ) !== $connection['workspace_id'] ) { return array( 'status' => 410, 'reason' => 'otherSite', ); } return array( 'status' => 202, 'queued' => Imports::enqueue_push( $id, $clip, (int) $connection['connected_by'], $ticket, is_string( $variant ) ? $variant : '' ), ); } /** * ShowBits' check while connecting: the HMAC of its challenge under the pairing value it names, or nothing (no such * attempt here). A ShowBits that names none (before pairingId: an older OnPremise) gets the latest attempt's. * * @param array $message The check. * @param callable|null $pairing The pairing value of an attempt by its id. * @return array{status: int, reason?: string, proof?: string} */ private static function verify( array $message, ?callable $pairing ): array { $challenge = (string) ( $message['challenge'] ?? '' ); $id = isset( $message['pairingId'] ) ? (string) $message['pairingId'] : null; if ( ! preg_match( '/^[A-Za-z0-9_-]{43}$/', $challenge ) || ( null !== $id && ! preg_match( '/^[A-Za-z0-9_-]{43}$/', $id ) ) ) { return array( 'status' => 422, 'reason' => 'invalid', ); } $pairing = null === $pairing ? null : $pairing( $id ); if ( ! is_string( $pairing ) || ! preg_match( '/^[A-Za-z0-9_-]{43}$/', $pairing ) || ( null !== $id && ! hash_equals( Connection::pairing_id( $pairing ), $id ) ) ) { return array( 'status' => 404, 'reason' => 'notConnecting', ); } return array( 'status' => 200, 'proof' => self::proof( $pairing, $challenge ), ); } /** base64url(HMAC-SHA256(pairing, "showbits-site-verify:" . challenge)), as ShowBits expects (lib/wordpress-verify.ts). */ public static function proof( string $pairing, string $challenge ): string { return rtrim( strtr( base64_encode( hash_hmac( 'sha256', 'showbits-site-verify:' . $challenge, $pairing, true ) ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- base64url of an HMAC, not obfuscation } }