true, 'message' => string ] or [ 'ok' => false, 'error' => string ]. */ public function request( string $plugin, string $email, array $hint, string $conversation_id, string $requester, string $token ): array { $email = strtolower( trim( $email ) ); if ( ! is_email( $email ) ) { return [ 'ok' => false, 'error' => __( 'Please enter a valid email address.', 'aisentic' ), ]; } // Only a card the AI made for this email can send: a script cannot // post any address it likes. Says nothing about bookings. if ( ! self::token_valid( $email, $token ) ) { return [ 'ok' => false, 'error' => __( 'This request has expired. Please ask again in the chat.', 'aisentic' ), ]; } $senders = apply_filters( 'aisentic_manage_links_senders', [] ); $sender = is_array( $senders ) ? ( $senders[ $plugin ] ?? null ) : null; if ( ! is_callable( $sender ) ) { return [ 'ok' => false, 'error' => __( 'This action is not available.', 'aisentic' ), ]; } $sent = [ 'ok' => true, /* translators: %s: the email address the visitor typed */ 'message' => sprintf( __( 'If there is an upcoming booking under %s, the links are on their way. Please check your inbox and spam folder.', 'aisentic' ), $email ), ]; // Key => [ limit, window ]. $day_key = 'aisentic_ml_ed_' . md5( $email ); $counters = [ $day_key => [ self::EMAIL_DAY_LIMIT, DAY_IN_SECONDS ], 'aisentic_ml_c_' . md5( $conversation_id ) => [ self::CONVERSATION_LIMIT, HOUR_IN_SECONDS ], 'aisentic_ml_r_' . md5( $requester ) => [ self::REQUESTER_LIMIT, HOUR_IN_SECONDS ], ]; $admitted = $this->admit( $counters ); if ( null === $admitted ) { // Too busy to check the limits: nothing was sent, so do not say it was. return [ 'ok' => false, 'error' => __( 'Please try again in a moment.', 'aisentic' ), ]; } if ( ! $admitted && $this->is_full( $day_key, self::EMAIL_DAY_LIMIT ) ) { // Anyone can use up the daily cap for someone else's email. Saying // "on their way" left that customer waiting for nothing. Logged // without the address, so the owner can see when it happens. Log::warning( 'Manage links: the daily send cap was reached for an email address.' ); return [ 'ok' => false, 'error' => trim( __( 'Too many link requests for this email today.', 'aisentic' ) . ' ' . $this->contact_message( $plugin ) ), ]; } if ( ! $admitted ) { return $sent; } $clean = $this->clean_hint( $hint ); // Building and mailing the list takes time only when bookings exist, so the // send must never run while the visitor waits: response time would reveal // who is a customer. PHP-FPM and LiteSpeed can end the response first and // send right after; any other host hands the send to a one-off cron event. if ( $this->can_finish_response() ) { add_action( 'shutdown', static function () use ( $plugin, $email, $clean ): void { // Booktics opens a PHP session on every request. Held open, it // makes the visitor's next request wait for this send, and // that wait would show whether the email has bookings. if ( PHP_SESSION_ACTIVE === session_status() ) { session_write_close(); } if ( function_exists( 'fastcgi_finish_request' ) ) { fastcgi_finish_request(); } elseif ( function_exists( 'litespeed_finish_request' ) ) { litespeed_finish_request(); } self::run_queued( $plugin, $email, $clean ); } ); return $sent; } // The cron option is stored as plain text, so it holds only a key; the // email waits in a short-lived transient. The same send gives the same // key, so WordPress still drops a double tap within 10 minutes. // spawn_cron() is a fire-and-forget request. $key = 'aisentic_ml_q_' . md5( wp_json_encode( [ $plugin, $email, $clean ] ) ); set_transient( $key, [ $plugin, $email, $clean ], HOUR_IN_SECONDS ); wp_schedule_single_event( time(), self::CRON_HOOK, [ $key ] ); spawn_cron(); return $sent; } /** * Token for the card the AI shows for this email: when it expires, signed * with the site's secret salt. It also signs this browser's visitor cookie, * so a card made in one chat cannot be used from another browser. * * @param string $email Email the card is for. * @param int|null $expires Unix time it stops working (default: TOKEN_TTL from now). * @return string */ public static function token( string $email, ?int $expires = null ): string { $expires = $expires ?? time() + self::TOKEN_TTL; return $expires . '.' . hash_hmac( 'sha256', strtolower( trim( $email ) ) . '|' . self::visitor() . '|' . $expires, wp_salt( 'nonce' ) ); } /** * This browser's visitor cookie, '' when it has none. * * @return string */ private static function visitor(): string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- isset() is checked by ??. return sanitize_text_field( wp_unslash( (string) ( $_COOKIE[ Chat_Controller::VISITOR_COOKIE ] ?? '' ) ) ); } /** * Whether a token was made for this email and has not expired. * * @param string $email Lowercase email. * @param string $token Token from the card. * @return bool */ private static function token_valid( string $email, string $token ): bool { $expires = (int) strtok( $token, '.' ); return $expires >= time() && hash_equals( self::token( $email, $expires ), $token ); } /** * Run a send queued on cron (see request()). * * @param string $key Transient holding [ plugin, email, hint ]. * @return void */ public static function run_queued_payload( string $key ): void { $payload = get_transient( $key ); delete_transient( $key ); if ( is_array( $payload ) && 3 === count( $payload ) ) { self::run_queued( (string) $payload[0], (string) $payload[1], (array) $payload[2] ); } } /** * Run one send, after the response or from cron. Errors are logged, never shown. * * @param string $plugin Plugin slug. * @param string $email Email. * @param array $hint Clean hint. * @return void */ public static function run_queued( string $plugin, string $email, array $hint ): void { $senders = apply_filters( 'aisentic_manage_links_senders', [] ); $sender = is_array( $senders ) ? ( $senders[ $plugin ] ?? null ) : null; if ( ! is_callable( $sender ) ) { return; } try { call_user_func( $sender, $email, $hint ); } catch ( \Throwable $e ) { // No retry: a second try could send the same links twice. Log::error( 'Manage links send failed: ' . $e->getMessage() ); } } /** * Whether a counter has used its whole window, without counting a try. * * @param string $key Counter key. * @param int $limit Its limit. * @return bool */ private function is_full( string $key, int $limit ): bool { if ( wp_using_ext_object_cache() ) { return (int) wp_cache_get( $key, self::CACHE_GROUP ) >= $limit; } $state = get_transient( $key ); return is_array( $state ) && (int) ( $state['until'] ?? 0 ) > time() && (int) $state['n'] >= $limit; } /** * The plugin's "how to reach us" text, e.g. Booktics' Contact Message. * * @param string $plugin Plugin slug. * @return string '' when the plugin has none. */ protected function contact_message( string $plugin ): string { return (string) ( new Public_Agent_Settings() )->integration( $plugin, 'contactMessage' ); } /** * Count this send against every limit, atomically. * * Each counter keeps the end of its window, so a busy visitor cannot keep * one window open by sending again. A refused try counts nowhere. * * With an object cache the counters use wp_cache_incr, which is atomic, and * a refused try is taken back. Without one, the check and the count run under * a short database lock, so two taps at the same moment cannot both slip * under a limit. * * @param array $counters Counter key => [ limit, window seconds ]. * @return bool|null True when the send may go ahead, false when a limit says no, * null when the lock stayed busy and nothing could be checked. */ private function admit( array $counters ): ?bool { if ( wp_using_ext_object_cache() ) { $counted = []; $over = false; foreach ( $counters as $key => [ $limit, $window ] ) { // The window starts with the first send; incr keeps its expiry. wp_cache_add( $key, 0, self::CACHE_GROUP, $window ); $count = wp_cache_incr( $key, 1, self::CACHE_GROUP ); if ( false === $count ) { // Evicted between add and incr: start the window again. wp_cache_set( $key, 1, self::CACHE_GROUP, $window ); $count = 1; } $counted[] = $key; $over = $over || (int) $count > $limit; } if ( $over ) { foreach ( $counted as $key ) { wp_cache_decr( $key, 1, self::CACHE_GROUP ); } } return ! $over; } if ( ! $this->acquire_lock() ) { return null; } try { $now = time(); $windows = []; foreach ( $counters as $key => [ $limit, $window ] ) { $state = get_transient( $key ); if ( ! is_array( $state ) || (int) ( $state['until'] ?? 0 ) <= $now ) { $state = [ 'n' => 0, 'until' => $now + $window, ]; } if ( (int) $state['n'] >= $limit ) { return false; } $windows[ $key ] = $state; } foreach ( $windows as $key => $state ) { // set_transient() restarts the expiry, so pass only what is left. set_transient( $key, [ 'n' => (int) $state['n'] + 1, 'until' => (int) $state['until'], ], max( 1, (int) $state['until'] - $now ) ); } return true; } finally { $this->release_lock(); } } /** * Whether this host can end the HTTP response before the send runs. * * @return bool */ protected function can_finish_response(): bool { return function_exists( 'fastcgi_finish_request' ) || function_exists( 'litespeed_finish_request' ); } /** * Take the send lock: an options row only one request can insert, the way * WP_Upgrader::create_lock() does it. Waits up to half a second. * * @return bool False when another request kept it the whole time. */ protected function acquire_lock(): bool { global $wpdb; // Time first, for the stale check below; the random part makes the row // ours, so release_lock() never deletes a lock another request holds. $this->lock_value = time() . '|' . wp_generate_password( 12, false ); for ( $try = 0; $try < 10; $try++ ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- an atomic insert is the lock; a cached read would defeat it. $made = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} ( option_name, option_value, autoload ) VALUES ( %s, %s, 'no' )", self::LOCK, $this->lock_value ) ); if ( $made ) { return true; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- must see the row as it is now. $held = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", self::LOCK ) ); $since = (int) $held; if ( $since > 0 && $since < time() - 10 ) { // Left behind by a request that died while holding it. Delete it // only if it is still that row: another request may have just // cleared it and taken a fresh lock. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- compare-and-delete must hit the row as it is now. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", self::LOCK, $held ) ); continue; } usleep( 50000 ); } return false; } /** * Give the send lock back, only if it is still ours: a request that ran past * the stale time may have lost it to another one. * * @return void */ protected function release_lock(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- pairs with the insert above. $wpdb->delete( $wpdb->options, [ 'option_name' => self::LOCK, 'option_value' => $this->lock_value, ] ); } /** * Keep only the hint fields the sender understands, in a safe shape. * * @param array $hint Raw hint. * @return array */ private function clean_hint( array $hint ): array { $date = (string) ( $hint['date'] ?? '' ); $intent = (string) ( $hint['intent'] ?? '' ); return [ 'service' => mb_substr( sanitize_text_field( (string) ( $hint['service'] ?? '' ) ), 0, 100 ), 'date' => 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ? $date : '', 'intent' => in_array( $intent, self::INTENTS, true ) ? $intent : 'view', ]; } }