%s', esc_url( self::page_url() ), esc_html__( 'Settings', 'cronengine' ) ) ); return $links; } public static function page_url( $args = array() ) { return add_query_arg( array_merge( array( 'page' => self::PAGE ), $args ), admin_url( 'tools.php' ) ); } // --------------------------------------------------------------------- // Actions // --------------------------------------------------------------------- /** * Connect: remember a state and a verifier, and send the admin to CronEngine. * * On a copied site the connection it carries is dropped first. It belongs * to the address it was copied from, which keeps it. */ public static function start_connect() { self::guard( 'cronengine_connect' ); if ( CronEngine_State::is_copy() ) { CronEngine_State::forget(); } self::send_to_connect(); } /** * The site was moved, not copied: delete the old address's connection in * CronEngine, so it stops calling an address that is gone, then connect * this one. */ public static function moved() { self::guard( 'cronengine_moved' ); if ( ! CronEngine_State::is_copy() ) { self::back( '' ); } $result = CronEngine_Api::disconnect_old_address(); // Already gone on CronEngine's side is as good as deleted. if ( ! $result['ok'] && 401 !== $result['status'] ) { self::back( 'moved_failed', $result['message'] ); } CronEngine_State::forget(); self::send_to_connect(); } private static function send_to_connect() { $site_url = CronEngine_State::current_url(); $state = wp_generate_password( 40, false ); $verifier = wp_generate_password( 64, false ); set_transient( self::PENDING . get_current_user_id(), array( 'state' => $state, 'verifier' => $verifier, 'site_url' => $site_url, ), 30 * MINUTE_IN_SECONDS ); wp_safe_redirect( CronEngine_Api::connect_url( array( 'site_url' => $site_url, 'return_url' => self::page_url(), 'state' => $state, 'code_challenge' => rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ), 'code_challenge_method' => 'S256', 'site_name' => get_bloginfo( 'name' ), 'plugin_version' => CRONENGINE_VERSION, ) ) ); exit; } /** * Back from CronEngine, with a code or a cancellation. * * Runs before the page renders, and always ends in a redirect to the bare * page, so the code never stays in the address bar, the history, or a * Referer header. */ public static function handle_return() { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- `state` is the check here: it was generated for this admin and only CronEngine has it. if ( ! isset( $_GET['cronengine_code'] ) && ! isset( $_GET['cronengine_error'] ) ) { return; } if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You are not allowed to connect this site.', 'cronengine' ) ); } $pending = get_transient( self::PENDING . get_current_user_id() ); $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; $code = isset( $_GET['cronengine_code'] ) ? sanitize_text_field( wp_unslash( $_GET['cronengine_code'] ) ) : ''; $error = isset( $_GET['cronengine_error'] ) ? sanitize_key( wp_unslash( $_GET['cronengine_error'] ) ) : ''; // phpcs:enable if ( ! is_array( $pending ) || '' === $state || ! hash_equals( (string) $pending['state'], $state ) ) { self::back( 'expired' ); } delete_transient( self::PENDING . get_current_user_id() ); if ( '' !== $error ) { self::back( 'cancelled' ); } $result = CronEngine_Api::exchange( $code, (string) $pending['verifier'], (string) $pending['site_url'] ); if ( ! $result['ok'] || empty( $result['data']['key'] ) ) { self::back( 'error', $result['message'] ); } CronEngine_State::connect( sanitize_text_field( (string) $result['data']['key'] ), (string) $pending['site_url'], isset( $result['data']['site'] ) && is_array( $result['data']['site'] ) ? $result['data']['site'] : null ); self::back( 'connected' ); } public static function change_interval() { self::guard( 'cronengine_interval' ); $minutes = isset( $_POST['minutes'] ) ? absint( wp_unslash( $_POST['minutes'] ) ) : 0; $result = CronEngine_Api::set_interval( $minutes ); if ( $result['ok'] ) { if ( isset( $result['data']['site'] ) && is_array( $result['data']['site'] ) ) { CronEngine_State::remember_status( $result['data']['site'] ); } else { delete_transient( CronEngine_State::STATUS_CACHE ); } self::back( 'saved' ); } self::handle_failure( $result ); } public static function disconnect() { self::guard( 'cronengine_disconnect' ); // A copy only lets go of what it carries. The original stays connected. if ( CronEngine_State::is_copy() ) { CronEngine_State::forget(); self::back( 'copy_cleared' ); } $result = CronEngine_Api::disconnect(); // Already gone on CronEngine's side is as good as disconnected. if ( $result['ok'] || 401 === $result['status'] ) { CronEngine_State::forget(); self::back( 'disconnected' ); } self::back( 'disconnect_failed', $result['message'] ); } /** Forget the connection on this site only, for when CronEngine cannot be reached. */ public static function forget() { self::guard( 'cronengine_forget' ); CronEngine_State::forget(); self::back( 'forgotten' ); } public static function dismiss() { self::guard( 'cronengine_dismiss' ); update_user_meta( get_current_user_id(), self::DISMISSED, 1 ); $back = wp_get_referer(); wp_safe_redirect( $back ? $back : admin_url() ); exit; } /** Capability and nonce, for every form on the page. */ private static function guard( $action ) { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You are not allowed to manage CronEngine.', 'cronengine' ) ); } check_admin_referer( $action ); } /** A failed call. A 401 means the key is gone, so the plugin lets go too. */ private static function handle_failure( array $result ) { if ( 401 === $result['status'] ) { CronEngine_State::forget(); self::back( 'revoked' ); } self::back( 'error', $result['message'] ); } /** Back to the page with a notice, and an optional message from CronEngine. */ private static function back( $notice, $message = '' ) { if ( '' !== $message ) { set_transient( self::MESSAGE . get_current_user_id(), $message, MINUTE_IN_SECONDS ); } wp_safe_redirect( self::page_url( array( 'cronengine_notice' => $notice ) ) ); exit; } // --------------------------------------------------------------------- // Reminder // --------------------------------------------------------------------- /** One reminder to connect, on the Dashboard and Plugins screens only. */ public static function reminder() { $screen = get_current_screen(); if ( ! $screen || ! in_array( $screen->id, array( 'dashboard', 'plugins' ), true ) ) { return; } if ( ! current_user_can( self::CAPABILITY ) || CronEngine_State::is_connected() ) { return; } if ( get_user_meta( get_current_user_id(), self::DISMISSED, true ) ) { return; } $dismiss = wp_nonce_url( admin_url( 'admin-post.php?action=cronengine_dismiss' ), 'cronengine_dismiss' ); ?>

' . esc_html( CronEngine_State::connected_label() ) . '' ); ?>

'; echo '

' . self::logo() . esc_html__( 'CronEngine', 'cronengine' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- logo() is a fixed SVG. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only, a fixed list of notices. $notice = isset( $_GET['cronengine_notice'] ) ? sanitize_key( wp_unslash( $_GET['cronengine_notice'] ) ) : ''; if ( $revoked ) { self::notice( 'revoked' ); } elseif ( '' !== $notice ) { self::notice( $notice ); } if ( CronEngine_State::is_connected() ) { self::render_connected( $status, $error ); } elseif ( CronEngine_State::is_copy() ) { self::render_copy(); } else { self::render_intro(); } echo ''; } /** * A copied or moved site. It carries another address's connection, which * is never used here. Connecting gives it its own; the moved case also * removes the old one, after a clear warning. */ private static function render_copy() { $old = CronEngine_State::connected_label(); $new = CronEngine_State::label( CronEngine_State::current_url() ); ?>

' . esc_html( $old ) . '' ); ?>

' . esc_html__( 'privacy policy', 'cronengine' ) . '', '' . esc_html__( 'terms', 'cronengine' ) . '' ); ?>

array( 'success', __( 'Connected. CronEngine now runs WP-Cron for this site. Want retries or Slack alerts? Use Open in CronEngine below.', 'cronengine' ) ), 'saved' => array( 'success', __( 'Interval saved.', 'cronengine' ) ), 'disconnected' => array( 'success', __( 'Disconnected. The cron job was deleted in CronEngine and WordPress runs cron on visits again.', 'cronengine' ) ), 'forgotten' => array( 'warning', __( 'The connection was removed from this site. If the cron job still exists in CronEngine, delete it under Settings, Connected sites.', 'cronengine' ) ), 'cancelled' => array( 'info', __( 'Nothing was connected.', 'cronengine' ) ), 'expired' => array( 'error', __( 'That connection attempt expired or did not start here. Click Connect to CronEngine to try again.', 'cronengine' ) ), 'revoked' => array( 'warning', __( 'This site was disconnected in CronEngine, so WordPress runs cron on visits again. Connect again to switch back.', 'cronengine' ) ), 'error' => array( 'error', '' !== $detail ? $detail : __( 'Something went wrong. Please try again.', 'cronengine' ) ), 'disconnect_failed' => array( 'error', '' !== $detail ? $detail : __( 'Could not disconnect. Please try again.', 'cronengine' ) ), 'copy_cleared' => array( 'success', __( 'The copied connection was removed from this site. The site it was copied from is still connected.', 'cronengine' ) ), 'moved_failed' => array( 'error', '' !== $detail ? $detail : __( 'Could not remove the old connection. Please try again, or use Connect this site.', 'cronengine' ) ), ); if ( ! isset( $notices[ $key ] ) ) { return; } list( $type, $text ) = $notices[ $key ]; echo '

' . esc_html( $text ) . '

'; if ( 'disconnect_failed' === $key ) { echo '

'; echo ''; wp_nonce_field( 'cronengine_forget' ); echo ''; echo '

'; } echo '
'; } /** * What an interval costs from the plan, as CronEngine counts it: a 30-day * month of calls. Shown on hover, for whoever shares one plan across sites. */ private static function runs_label( $minutes ) { $runs = (int) round( 43200 / max( 1, $minutes ) ); /* translators: %s: number of runs, for example "720" */ return sprintf( _n( 'About %s run a month', 'About %s runs a month', $runs, 'cronengine' ), number_format_i18n( $runs ) ); } private static function interval_label( $minutes ) { if ( 1 === $minutes ) { return __( 'Every minute', 'cronengine' ); } if ( 60 === $minutes ) { return __( 'Every hour', 'cronengine' ); } if ( $minutes > 60 && 0 === $minutes % 60 ) { $hours = intdiv( $minutes, 60 ); /* translators: %d: number of hours */ return sprintf( _n( 'Every %d hour', 'Every %d hours', $hours, 'cronengine' ), $hours ); } /* translators: %d: number of minutes */ return sprintf( _n( 'Every %d minute', 'Every %d minutes', $minutes, 'cronengine' ), $minutes ); } /** A UTC ISO time from CronEngine, in this site's timezone and format. */ private static function when( $iso, $empty ) { $timestamp = is_string( $iso ) ? strtotime( $iso ) : false; if ( ! $timestamp ) { return $empty; } return wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp ); } private static function logo() { return ''; } }