Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plugins/bcc-login/includes/class-bcc-login-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ function render_options_page() { ?>
<?php settings_errors('groups_cache_flushed'); ?>
<?php $this->render_delete_subscribers_button( ); ?>
<?php settings_errors('subscribers_deleted'); ?>
<?php do_action( 'bcc_login_settings_maintenance' ); ?>
</div>
<?php
}
Expand Down
178 changes: 149 additions & 29 deletions plugins/bcc-login/includes/class-bcc-login-site-report.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* how the widgets API knows which site sent it.
*/
class BCC_Login_Site_Report {
const LOCK = 'bcc_login_site_report';
const LOCK_OPTION = 'bcc_login_site_report.lock';

private BCC_Login_Settings $_settings;
private $plugin;
Expand All @@ -22,49 +22,132 @@ function __construct( BCC_Login_Settings $settings, $plugin, $plugin_version ) {

// After the daily cleanup, so the leftover count shows what it couldn't remove yet.
add_action( BCC_Login_Cleanup::CRON_HOOK, array( $this, 'send' ), 20 );
add_action( 'bcc_login_settings_maintenance', array( $this, 'render_send_now_button' ) );
}

/**
* Failures are logged and never stop the daily task.
* The daily report. Failures are logged and never stop the daily task.
*/
function send() {
// WP-Cron can run the daily task more often, even twice at once, e.g. when it can't save the next run
// time. So the report is sent at most once in 12 hours, claimed with WordPress's upgrader lock: an
// option added atomically, which expires. It's claimed before asking for a token, so a site that can't
// get one doesn't keep asking either.
self::load_upgrader();
if ( ! WP_Upgrader::create_lock( self::LOCK, 12 * HOUR_IN_SECONDS ) ) {
// time. So the report is sent at most once in 12 hours. The time is claimed before asking for a token,
// so a site that can't get one doesn't keep asking either.
if ( ! self::claim() ) {
return;
}

if ( empty( $this->_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;
}
?>
<h3><?php _e( 'Site report', 'bcc-login' ); ?></h3>
<p><?php _e( 'Once a day, this site sends BCC its bcc-login, WordPress and PHP versions and its number of users and subscribers. Send it now to check that this works, for example after allowing the site\'s login client to get a token for the widgets API.', 'bcc-login' ); ?></p>
<form method="post">
<?php wp_nonce_field( 'send_site_report', 'send_site_report_nonce' ); ?>
<?php submit_button( __( 'Send site report now', 'bcc-login' ), 'secondary', 'send_site_report', false ); ?>
</form>
<?php
$this->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() {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -147,17 +269,15 @@ 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 ) );

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;
Expand Down
Loading