From a9c505308b73144430b42beffa1f876772752fa2 Mon Sep 17 00:00:00 2001 From: Simon Costea Date: Wed, 21 Oct 2026 18:53:10 +0300 Subject: [PATCH] Add a "Send site report now" button, and keep the report going with an object cache The BCC Login settings page gets a Site report section under Maintenance, for administrators. Its button sends the report right away and shows what was sent or why it couldn't be sent, such as Auth0's error. It clears the last claim instead of claiming the next 12 hours, so the next daily report still goes out at its usual time. The 12-hour window is now claimed with SQL instead of WP_Upgrader::create_lock(). Re-creating an expired lock left the option cached as missing, so with a persistent object cache the daily report stopped after two days. INSERT IGNORE adds the first claim and a guarded UPDATE moves it forward. Both count a row only when they claim it, also on connections with MYSQLI_CLIENT_FOUND_ROWS. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../includes/class-bcc-login-settings.php | 1 + .../includes/class-bcc-login-site-report.php | 178 +++++++++++++++--- 2 files changed, 150 insertions(+), 29 deletions(-) diff --git a/plugins/bcc-login/includes/class-bcc-login-settings.php b/plugins/bcc-login/includes/class-bcc-login-settings.php index b38e599..f194da0 100644 --- a/plugins/bcc-login/includes/class-bcc-login-settings.php +++ b/plugins/bcc-login/includes/class-bcc-login-settings.php @@ -596,6 +596,7 @@ function render_options_page() { ?> render_delete_subscribers_button( ); ?> + _settings->client_id ) || empty( $this->_settings->client_secret ) ) { - error_log( 'BCC Login: The site report needs the client id and secret of the login client.' ); - return; + $sent = $this->deliver(); + if ( is_wp_error( $sent ) ) { + error_log( 'BCC Login: ' . $sent->get_error_message() ); } + } - $token = $this->get_token(); - if ( ! $token ) { - return; + /** + * Sends the report right away, however recently it was sent, e.g. to check the site's setup in Auth0. + * It clears the last claim rather than claiming the next 12 hours, so the next daily report is still sent + * at its usual time. + * + * @return array|WP_Error The report that was sent, or why it couldn't be sent. + */ + function send_now() { + global $wpdb; + + $wpdb->delete( $wpdb->options, array( 'option_name' => self::LOCK_OPTION ) ); + + return $this->deliver(); + } + + /** + * Claims the next 12 hours for sending the daily report: true when the last claim is older than that. Of + * two runs at once, only one can claim them. + * + * The time is kept in an option, but read and written only with SQL. WordPress's own lock + * (WP_Upgrader::create_lock) goes through the option cache, and re-creating an expired lock leaves the + * option cached as missing. With a persistent object cache that stopped the daily report after two days. + */ + private static function claim() { + global $wpdb; + + $now = time(); + + // The first claim adds the option. Of two runs at once, only one can add it. + $added = $wpdb->query( $wpdb->prepare( + "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %d, 'off')", + self::LOCK_OPTION, + $now + ) ); + if ( $added > 0 ) { + return true; } - $response = wp_remote_post( rtrim( $this->_settings->widgets_base_url, '/' ) . '/api/wordpress-sites/report', array( - 'timeout' => 15, - 'headers' => array( - 'Authorization' => 'Bearer ' . $token, - 'Content-Type' => 'application/json', - ), - 'body' => wp_json_encode( $this->get_report() ), + // Later claims move the time forward once it's 12 hours old. Of two runs at once, the second waits for + // the first and then finds the new time. + $moved = $wpdb->query( $wpdb->prepare( + "UPDATE {$wpdb->options} SET option_value = %d WHERE option_name = %s AND CAST(option_value AS UNSIGNED) <= %d", + $now, + self::LOCK_OPTION, + $now - 12 * HOUR_IN_SECONDS ) ); - if ( is_wp_error( $response ) ) { - error_log( 'BCC Login: Could not send the site report: ' . $response->get_error_message() ); + // Both statements count a row only when they claim it, also on connections that count matched rows + // instead of changed ones (MYSQLI_CLIENT_FOUND_ROWS). + return $moved > 0; + } + + /** + * The "Send site report now" button under Maintenance on the settings page, and what happened when it was + * pressed. Like the rest of Maintenance, only administrators see it. + */ + function render_send_now_button() { + if ( ! current_user_can( 'administrator' ) ) { return; } + ?> +

+

+
+ + +
+ send_now_handler(); + settings_errors( 'site_report_sent' ); + } - $status = wp_remote_retrieve_response_code( $response ); - if ( $status < 200 || $status >= 300 ) { - error_log( 'BCC Login: The widgets API refused the site report with HTTP ' . $status . '.' ); + /** + * Sends the report when the button was pressed, and shows what was sent or why it couldn't be sent. + */ + function send_now_handler() { + if ( + ! isset( $_POST['send_site_report'], $_POST['send_site_report_nonce'] ) || + ! wp_verify_nonce( $_POST['send_site_report_nonce'], 'send_site_report' ) || + ! current_user_can( 'administrator' ) + ) { + return; } + + $report = $this->send_now(); + + // settings_errors() prints messages as HTML, and Auth0's error description comes from outside. + if ( is_wp_error( $report ) ) { + add_settings_error( 'site_report_sent', 'site_report_not_sent', esc_html( $report->get_error_message() ), 'error' ); + return; + } + + add_settings_error( + 'site_report_sent', + 'site_report_sent', + esc_html( sprintf( + __( 'The site report was sent: %1$s, bcc-login %2$s, WordPress %3$s, PHP %4$s, users: %5$s, subscribers: %6$s.', 'bcc-login' ), + $report['siteUrl'], + $report['pluginVersion'], + $report['wordpressVersion'], + $report['phpVersion'], + null === $report['users'] ? '?' : $report['users'], + null === $report['subscribers'] ? '?' : $report['subscribers'] + ) ), + 'success' + ); } function get_report() { @@ -105,6 +188,43 @@ function get_report() { ); } + /** + * Gets a token for the site's login client and posts the report to the widgets API. + * + * @return array|WP_Error The report that was sent, or why it couldn't be sent. + */ + private function deliver() { + if ( empty( $this->_settings->client_id ) || empty( $this->_settings->client_secret ) ) { + return new WP_Error( 'bcc_login_site_report', 'The site report needs the client id and secret of the login client.' ); + } + + $token = $this->get_token(); + if ( is_wp_error( $token ) ) { + return $token; + } + + $report = $this->get_report(); + $response = wp_remote_post( rtrim( $this->_settings->widgets_base_url, '/' ) . '/api/wordpress-sites/report', array( + 'timeout' => 15, + 'headers' => array( + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json', + ), + 'body' => wp_json_encode( $report ), + ) ); + + if ( is_wp_error( $response ) ) { + return new WP_Error( 'bcc_login_site_report', 'Could not send the site report: ' . $response->get_error_message() ); + } + + $status = wp_remote_retrieve_response_code( $response ); + if ( $status < 200 || $status >= 300 ) { + return new WP_Error( 'bcc_login_site_report', 'The widgets API refused the site report with HTTP ' . $status . '.' ); + } + + return $report; + } + /** * Whether WordPress updates this plugin by itself, as its background updater decides: automatic updates * aren't turned off (e.g. by DISALLOW_FILE_MODS), it can write the files without asking for credentials, @@ -134,6 +254,8 @@ private static function load_upgrader() { /** * A token for the widgets API, for the site's own login client. + * + * @return string|WP_Error */ private function get_token() { $response = wp_remote_post( $this->_settings->token_endpoint, array( @@ -147,8 +269,7 @@ private function get_token() { ) ); if ( is_wp_error( $response ) ) { - error_log( 'BCC Login: Could not get a token for the site report: ' . $response->get_error_message() ); - return null; + return new WP_Error( 'bcc_login_site_report', 'Could not get a token for the site report: ' . $response->get_error_message() ); } $body = json_decode( wp_remote_retrieve_body( $response ) ); @@ -156,8 +277,7 @@ private function get_token() { if ( 200 !== wp_remote_retrieve_response_code( $response ) || empty( $body->access_token ) ) { // Auth0's error code and description only, e.g. when the login client may not get a token for the widgets API yet. $error = isset( $body->error ) ? $body->error . ( isset( $body->error_description ) ? ': ' . $body->error_description : '' ) : 'HTTP ' . wp_remote_retrieve_response_code( $response ); - error_log( 'BCC Login: Could not get a token for the site report: ' . $error ); - return null; + return new WP_Error( 'bcc_login_site_report', 'Could not get a token for the site report: ' . $error ); } return $body->access_token;