| 1 |
<?php |
| 2 |
/** |
| 3 |
* The class responsible for monitoring Square webhooks health. |
| 4 |
* |
| 5 |
* @package Charitable Square/Classes/Charitable_Square_WebhooksHealthCheck |
| 6 |
* @author David Bisset |
| 7 |
* @copyright Copyright (c) 2021-2022, WP Charitable LLC |
| 8 |
* @license http://opensource.org/licenses/gpl-2.0.php GNU Public License |
| 9 |
* @since 1.1.0 |
| 10 |
* @version 1.3.0 |
| 11 |
*/ |
| 12 |
|
| 13 |
// Exit if accessed directly. |
| 14 |
if ( ! defined( 'ABSPATH' ) ) { |
| 15 |
exit; |
| 16 |
} |
| 17 |
|
| 18 |
if ( ! class_exists( 'Charitable_Square_WebhooksHealthCheck' ) ) : |
| 19 |
|
| 20 |
/** |
| 21 |
* Webhooks Health Check class. |
| 22 |
* |
| 23 |
* @since 1.8.7 |
| 24 |
*/ |
| 25 |
class Charitable_Square_WebhooksHealthCheck { |
| 26 |
|
| 27 |
/** |
| 28 |
* Endpoint status option name. |
| 29 |
* |
| 30 |
* @since 1.8.7 |
| 31 |
*/ |
| 32 |
public const ENDPOINT_OPTION = 'charitable_square_webhooks_endpoint_status'; |
| 33 |
|
| 34 |
/** |
| 35 |
* Webhook health check cron hook. |
| 36 |
* |
| 37 |
* @since 1.8.7 |
| 38 |
*/ |
| 39 |
private const CRON_HOOK = 'charitable_square_webhooks_health_check'; |
| 40 |
|
| 41 |
/** |
| 42 |
* Status OK key. |
| 43 |
* |
| 44 |
* @since 1.8.7 |
| 45 |
*/ |
| 46 |
public const STATUS_OK = 'ok'; |
| 47 |
|
| 48 |
/** |
| 49 |
* Status error key. |
| 50 |
* |
| 51 |
* @since 1.8.7 |
| 52 |
*/ |
| 53 |
private const STATUS_ERROR = 'error'; |
| 54 |
|
| 55 |
/** |
| 56 |
* Admin notice ID. |
| 57 |
* |
| 58 |
* @since 1.8.7 |
| 59 |
*/ |
| 60 |
private const NOTICE_ID = 'charitable_square_webhooks_site_health'; |
| 61 |
|
| 62 |
/** |
| 63 |
* Missing signing secret notice ID. |
| 64 |
* |
| 65 |
* @since 1.8.12 |
| 66 |
*/ |
| 67 |
private const MISSING_SECRET_NOTICE_ID = 'charitable_square_webhooks_missing_secret'; |
| 68 |
|
| 69 |
/** |
| 70 |
* Initialization. |
| 71 |
* |
| 72 |
* @since 1.8.7 |
| 73 |
*/ |
| 74 |
public function init() { |
| 75 |
$this->hooks(); |
| 76 |
return $this; |
| 77 |
} |
| 78 |
|
| 79 |
/** |
| 80 |
* Register hooks. |
| 81 |
* |
| 82 |
* @since 1.8.7 |
| 83 |
*/ |
| 84 |
private function hooks() { |
| 85 |
add_action( 'admin_notices', array( $this, 'admin_notice' ) ); |
| 86 |
add_action( self::CRON_HOOK, array( $this, 'process_webhooks_status_check' ) ); |
| 87 |
add_action( 'init', array( $this, 'maybe_schedule_check' ) ); |
| 88 |
|
| 89 |
// Hook into the charitable_uninstall action for cleanup. |
| 90 |
add_action( 'charitable_uninstall', array( $this, 'uninstall' ) ); |
| 91 |
} |
| 92 |
|
| 93 |
/** |
| 94 |
* Schedule webhook health check using WP Cron. |
| 95 |
* |
| 96 |
* @since 1.8.7 |
| 97 |
*/ |
| 98 |
public function maybe_schedule_check() { |
| 99 |
// Skip if webhook health check should be disabled. |
| 100 |
if ( apply_filters( 'charitable_square_webhooks_health_check_disable', false ) ) { |
| 101 |
return; |
| 102 |
} |
| 103 |
|
| 104 |
// Skip if not configured or already scheduled. |
| 105 |
if ( ! Charitable_Square_Helpers::is_webhook_enabled() || wp_next_scheduled( self::CRON_HOOK ) ) { |
| 106 |
return; |
| 107 |
} |
| 108 |
|
| 109 |
// Schedule health check to run hourly. |
| 110 |
wp_schedule_event( time(), 'hourly', self::CRON_HOOK ); |
| 111 |
} |
| 112 |
|
| 113 |
/** |
| 114 |
* Clean up scheduled events and options on uninstall. |
| 115 |
* |
| 116 |
* @since 1.8.7 |
| 117 |
*/ |
| 118 |
public function uninstall() { |
| 119 |
// Clear cron event. |
| 120 |
$timestamp = wp_next_scheduled( self::CRON_HOOK ); |
| 121 |
if ( $timestamp ) { |
| 122 |
wp_unschedule_event( $timestamp, self::CRON_HOOK ); |
| 123 |
} |
| 124 |
|
| 125 |
// Clean up options. |
| 126 |
delete_option( self::ENDPOINT_OPTION ); |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* Process webhook health check status. |
| 131 |
* |
| 132 |
* @since 1.8.7 |
| 133 |
*/ |
| 134 |
public function process_webhooks_status_check() { |
| 135 |
// Skip if webhooks are not enabled. |
| 136 |
if ( ! Charitable_Square_Helpers::is_webhook_enabled() ) { |
| 137 |
return; |
| 138 |
} |
| 139 |
|
| 140 |
$last_payment = $this->get_last_square_payment(); |
| 141 |
|
| 142 |
// If no Square payments found, remove status option to avoid false positives. |
| 143 |
if ( empty( $last_payment ) ) { |
| 144 |
delete_option( self::ENDPOINT_OPTION ); |
| 145 |
return; |
| 146 |
} |
| 147 |
|
| 148 |
// If payment is processed but webhook didn't update status within 15 minutes, |
| 149 |
// we likely have a webhook configuration issue. |
| 150 |
if ( |
| 151 |
isset( $last_payment['status'] ) && |
| 152 |
$last_payment['status'] === 'processed' && |
| 153 |
time() > ( strtotime( $last_payment['date_created_gmt'] ) + ( 15 * MINUTE_IN_SECONDS ) ) |
| 154 |
) { |
| 155 |
Charitable_Square_Helpers::reset_webhook_configuration(); |
| 156 |
self::save_status( self::STATUS_ERROR ); |
| 157 |
return; |
| 158 |
} |
| 159 |
|
| 160 |
// Everything looks good. |
| 161 |
self::save_status( self::STATUS_OK ); |
| 162 |
} |
| 163 |
|
| 164 |
/** |
| 165 |
* Get the most recent Square payment. |
| 166 |
* |
| 167 |
* @since 1.8.7 |
| 168 |
* |
| 169 |
* @return array Payment data or empty array if none found. |
| 170 |
*/ |
| 171 |
private function get_last_square_payment() { |
| 172 |
global $wpdb; |
| 173 |
|
| 174 |
// Find the last 'donation' custom post type with a meta_key of 'gateway' set to 'square'. |
| 175 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQLPlaceholders.UnnecessaryPrepare |
| 176 |
$payments = $wpdb->get_results( |
| 177 |
"SELECT * FROM {$wpdb->posts} p |
| 178 |
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id |
| 179 |
WHERE p.post_type = 'donation' |
| 180 |
AND p.post_status = 'charitable-completed' |
| 181 |
AND pm.meta_key = 'donation_gateway' |
| 182 |
AND pm.meta_value = 'square' |
| 183 |
ORDER BY ID DESC |
| 184 |
LIMIT 1", |
| 185 |
ARRAY_A |
| 186 |
); |
| 187 |
|
| 188 |
return $payments ? $payments[0] : array(); |
| 189 |
} |
| 190 |
|
| 191 |
/** |
| 192 |
* Display admin notice about webhook issues. |
| 193 |
* |
| 194 |
* @since 1.8.7 |
| 195 |
*/ |
| 196 |
public function admin_notice() { |
| 197 |
// Only show notice if Square is configured. |
| 198 |
if ( ! Charitable_Square_Helpers::is_square_configured() ) { |
| 199 |
return; |
| 200 |
} |
| 201 |
|
| 202 |
// Only show if webhooks are enabled but not properly configured. |
| 203 |
if ( |
| 204 |
! Charitable_Square_Helpers::is_webhook_enabled() || |
| 205 |
Charitable_Square_Helpers::is_webhook_configured() |
| 206 |
) { |
| 207 |
return; |
| 208 |
} |
| 209 |
|
| 210 |
// Only show if we've detected a webhook error. |
| 211 |
if ( get_option( self::ENDPOINT_OPTION, self::STATUS_OK ) === self::STATUS_OK ) { |
| 212 |
return; |
| 213 |
} |
| 214 |
|
| 215 |
// Only show if there are Square payments. |
| 216 |
if ( empty( $this->get_last_square_payment() ) ) { |
| 217 |
return; |
| 218 |
} |
| 219 |
|
| 220 |
// Display the admin notice. |
| 221 |
$notice = sprintf( |
| 222 |
wp_kses( |
| 223 |
/* translators: %1$s: URL to the webhooks documentation */ |
| 224 |
__( 'Looks like you have a problem with your webhooks configuration. Please check and confirm that you\'ve configured the WPCharitable webhooks in your Square account. This notice will disappear automatically when a new Square request comes in. See our <a href="%1$s" rel="nofollow noopener" target="_blank">documentation</a> for more information.', 'charitable' ), |
| 225 |
array( |
| 226 |
'a' => array( |
| 227 |
'href' => array(), |
| 228 |
'target' => array(), |
| 229 |
'rel' => array(), |
| 230 |
), |
| 231 |
) |
| 232 |
), |
| 233 |
esc_url( charitable_utm_link( 'https://wpcharitable.com/docs/setting-up-square-webhooks/', 'Admin', 'Square Webhooks not active' ) ) |
| 234 |
); |
| 235 |
|
| 236 |
// Use WordPress admin notices. |
| 237 |
?> |
| 238 |
<div class="notice notice-error is-dismissible" data-notice-id="<?php echo esc_attr( self::NOTICE_ID ); ?>"> |
| 239 |
<p><?php echo wp_kses_post( $notice ); ?></p> |
| 240 |
</div> |
| 241 |
<?php |
| 242 |
} |
| 243 |
|
| 244 |
/** |
| 245 |
* Warn when Square is connected but no webhook signing secret is stored. |
| 246 |
* |
| 247 |
* Since 1.8.12 the webhook processor fails closed when it has no signing |
| 248 |
* secret to verify against, so an affected site quietly stops reconciling |
| 249 |
* Square donations and refunds. The hourly health check cannot catch this: |
| 250 |
* it keys off the most recent *completed* Square donation, which is the |
| 251 |
* very thing a refused webhook prevents. Read the stored setting directly |
| 252 |
* instead so the warning does not depend on donation history or the cron. |
| 253 |
* |
| 254 |
* @since 1.8.12 |
| 255 |
*/ |
| 256 |
public function missing_signing_secret_notice() { |
| 257 |
|
| 258 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 259 |
return; |
| 260 |
} |
| 261 |
|
| 262 |
// Only warn sites that actually have a valid Square connection. |
| 263 |
if ( ! Charitable_Square_Helpers::is_square_configured() ) { |
| 264 |
return; |
| 265 |
} |
| 266 |
|
| 267 |
$mode = Charitable_Square_Helpers::get_mode(); |
| 268 |
$settings = charitable_get_option( 'gateways_square' ); |
| 269 |
|
| 270 |
// A secret is stored for the active mode, so verification works. |
| 271 |
if ( is_array( $settings ) && ! empty( $settings[ $mode ]['webhooks-secret'] ) ) { |
| 272 |
return; |
| 273 |
} |
| 274 |
|
| 275 |
$notice = sprintf( |
| 276 |
wp_kses( |
| 277 |
/* translators: %1$s: URL to the Square settings screen, %2$s: URL to the webhooks documentation */ |
| 278 |
__( 'Charitable cannot verify incoming Square webhooks because no webhook signing secret is stored for the current mode. Unverified webhooks are rejected, so Square donations and refunds will not update automatically. Add your signing secret on the <a href="%1$s">Square settings screen</a>. See our <a href="%2$s" rel="nofollow noopener" target="_blank">documentation</a> for the steps.', 'charitable' ), |
| 279 |
array( |
| 280 |
'a' => array( |
| 281 |
'href' => array(), |
| 282 |
'target' => array(), |
| 283 |
'rel' => array(), |
| 284 |
), |
| 285 |
) |
| 286 |
), |
| 287 |
esc_url( admin_url( 'admin.php?page=charitable-settings&tab=gateways&group=gateways_square_core' ) ), |
| 288 |
esc_url( charitable_utm_link( 'https://wpcharitable.com/docs/setting-up-square-webhooks/', 'Admin', 'Square Webhook Signing Secret Missing' ) ) |
| 289 |
); |
| 290 |
|
| 291 |
?> |
| 292 |
<div class="notice notice-error is-dismissible" data-notice-id="<?php echo esc_attr( self::MISSING_SECRET_NOTICE_ID ); ?>"> |
| 293 |
<p><?php echo wp_kses_post( $notice ); ?></p> |
| 294 |
</div> |
| 295 |
<?php |
| 296 |
} |
| 297 |
|
| 298 |
/** |
| 299 |
* Save webhooks status. |
| 300 |
* |
| 301 |
* @since 1.8.7 |
| 302 |
* |
| 303 |
* @param string $value Status value. |
| 304 |
*/ |
| 305 |
public static function save_status( string $value ) { |
| 306 |
if ( ! in_array( $value, array( self::STATUS_OK, self::STATUS_ERROR ), true ) ) { |
| 307 |
return; |
| 308 |
} |
| 309 |
|
| 310 |
update_option( self::ENDPOINT_OPTION, $value ); |
| 311 |
} |
| 312 |
} |
| 313 |
|
| 314 |
endif; |