connection = $connection; } /** * Registers the route. Runs on `rest_api_init`. */ public function register(): void { register_rest_route( self::NAMESPACE, self::ROUTE, array( 'methods' => 'POST', 'callback' => array( $this, 'handle' ), 'permission_callback' => array( $this, 'verify' ), ) ); } /** * Permission callback: true only for a fresh, correctly signed ping for this site. * * @param \WP_REST_Request $request Request. * @return true|\WP_Error */ public function verify( \WP_REST_Request $request ) { $site_id = (string) $request->get_header( 'x_showfm_site' ); $timestamp = (string) $request->get_header( 'x_showfm_timestamp' ); $nonce = (string) $request->get_header( 'x_showfm_nonce' ); $signature = strtolower( (string) $request->get_header( 'x_showfm_signature' ) ); if ( ! preg_match( '/^[0-9a-fA-F-]{36}$/', $site_id ) || ! preg_match( '/^[0-9]{1,12}$/', $timestamp ) || ! preg_match( '/^[A-Za-z0-9_+\/=-]{16,128}$/', $nonce ) || ! preg_match( '/^v1=[0-9a-f]{64}$/', $signature ) ) { return self::refused(); } if ( abs( time() - (int) $timestamp ) > self::WINDOW ) { return self::refused(); } $secret = $this->connection->ping_secret(); $stored = $this->connection->site_id(); if ( null === $secret || null === $stored || ! hash_equals( strtolower( $stored ), strtolower( $site_id ) ) ) { return self::refused(); } $expected = self::SCHEME . '=' . hash_hmac( 'sha256', self::SCHEME . '.' . $site_id . '.' . $timestamp . '.' . $nonce, $secret ); if ( ! hash_equals( $expected, $signature ) ) { return self::refused(); } // Only a correctly signed ping is remembered, so nobody else can fill the store. if ( ! self::claim( self::NONCE_PREFIX . hash( 'sha256', strtolower( $site_id ) . '.' . $nonce ) ) ) { return self::refused(); } return true; } /** * Claims a nonce for NONCE_TTL seconds. The INSERT IGNORE on the unique option_name * key is atomic, so when two copies of a ping arrive together only one claims it. * Expired claims are removed first, so the store holds at most 10 minutes of pings. * * Options are used rather than transients because a transient has no atomic add, and * the database stays the one store with or without a persistent object cache. * * @param string $name Option name for the nonce. * @return bool Whether this request claimed it. */ private static function claim( string $name ): bool { global $wpdb; $now = time(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Expired nonce claims are never read through the options API. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND CAST(option_value AS UNSIGNED) <= %d", $wpdb->esc_like( self::NONCE_PREFIX ) . '%', $now ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- add_option() is not atomic (it upserts), INSERT IGNORE is. $inserted = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off')", $name, (string) ( $now + self::NONCE_TTL ) ) ); return 1 === $inserted; } /** * Deletes every nonce claim. Runs on disconnect, when the ping secret they were signed * with is gone. */ public static function forget_nonces(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Nonce claims are never read through the options API. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( self::NONCE_PREFIX ) . '%' ) ); } /** * Queues one pull and starts WP-Cron. Ignores the body. Answers 202. */ public function handle(): \WP_REST_Response { if ( false === wp_next_scheduled( self::PULL_HOOK ) ) { wp_schedule_single_event( time(), self::PULL_HOOK ); } update_option( self::LAST_PING_OPTION, time(), false ); delete_option( self::MISSED_OPTION ); // Without this the pull waits for the next page view (before WordPress 6.9). spawn_cron(); $response = new \WP_REST_Response( null, 202 ); $response->header( 'Cache-Control', 'no-store' ); return $response; } /** * Called by the sync with the newest change it applied. show.fm pings the site for each * change and retries for a few minutes. A change made after the site connected, older * than that, with no ping since, means show.fm cannot reach the site, so new posts wait * for the 15-minute check. The next accepted ping clears this. * * @param int $changed_at When the change was made at show.fm, as a Unix timestamp. */ public static function note_change( int $changed_at ): void { if ( $changed_at <= Connection::connected_at() || time() - $changed_at < self::PING_GRACE || (int) get_option( self::LAST_PING_OPTION, 0 ) >= $changed_at ) { return; } if ( 0 === self::missed_at() ) { update_option( self::MISSED_OPTION, time(), false ); } } /** * When a missed ping was first noticed, or 0. */ public static function missed_at(): int { return (int) get_option( self::MISSED_OPTION, 0 ); } /** * The one refusal for every failure, so a caller learns nothing about which check failed. */ private static function refused(): \WP_Error { return new \WP_Error( 'showfm_ping_refused', 'Ping refused.', array( 'status' => 401 ) ); } }