| [ Index ] |
PHP Cross Reference of WordPress Trunk (Updated Daily) |
[Summary view] [Print] [Text view]
1 <?php 2 3 // We plan to gradually remove all of the disabled lint rules below. 4 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.MissingUnslash 5 // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 6 // phpcs:disable WordPress.Security.NonceVerification.Missing 7 // phpcs:disable Squiz.PHP.DisallowMultipleAssignments.FoundInControlStructure 8 9 class Akismet { 10 const API_HOST = 'rest.akismet.com'; 11 const API_PORT = 80; 12 const MAX_DELAY_BEFORE_MODERATION_EMAIL = 86400; // One day in seconds 13 const ALERT_CODE_COMMERCIAL = 30001; 14 15 // User account status constants 16 const USER_STATUS_ACTIVE = 'active'; 17 const USER_STATUS_NO_SUB = 'no-sub'; 18 const USER_STATUS_MISSING = 'missing'; 19 const USER_STATUS_CANCELLED = 'cancelled'; 20 const USER_STATUS_SUSPENDED = 'suspended'; 21 22 public static $limit_notices = array( 23 10501 => 'FIRST_MONTH_OVER_LIMIT', 24 10502 => 'SECOND_MONTH_OVER_LIMIT', 25 10504 => 'THIRD_MONTH_APPROACHING_LIMIT', 26 10508 => 'THIRD_MONTH_OVER_LIMIT', 27 10516 => 'FOUR_PLUS_MONTHS_OVER_LIMIT', 28 ); 29 30 private static $last_comment = ''; 31 private static $initiated = false; 32 private static $last_comment_result = null; 33 private static $comment_as_submitted_allowed_keys = array( 34 'blog' => '', 35 'blog_charset' => '', 36 'blog_lang' => '', 37 'blog_ua' => '', 38 'comment_agent' => '', 39 'comment_author' => '', 40 'comment_author_IP' => '', 41 'comment_author_email' => '', 42 'comment_author_url' => '', 43 'comment_content' => '', 44 'comment_date_gmt' => '', 45 'comment_tags' => '', 46 'comment_type' => '', 47 'guid' => '', 48 'is_test' => '', 49 'permalink' => '', 50 'reporter' => '', 51 'site_domain' => '', 52 'submit_referer' => '', 53 'submit_uri' => '', 54 'user_ID' => '', 55 'user_agent' => '', 56 'user_id' => '', 57 'user_ip' => '', 58 ); 59 60 public static function init() { 61 if ( ! self::$initiated ) { 62 self::init_hooks(); 63 } 64 } 65 66 /** 67 * Initializes WordPress hooks 68 */ 69 private static function init_hooks() { 70 self::$initiated = true; 71 72 add_action( 'wp_insert_comment', array( 'Akismet', 'auto_check_update_meta' ), 10, 2 ); 73 add_action( 'wp_insert_comment', array( 'Akismet', 'schedule_email_fallback' ), 10, 2 ); 74 add_action( 'wp_insert_comment', array( 'Akismet', 'schedule_approval_fallback' ), 10, 2 ); 75 76 add_filter( 'preprocess_comment', array( 'Akismet', 'auto_check_comment' ), 1 ); 77 add_filter( 'rest_pre_insert_comment', array( 'Akismet', 'rest_auto_check_comment' ), 1 ); 78 79 add_action( 'comment_form', array( 'Akismet', 'load_form_js' ) ); 80 add_action( 'do_shortcode_tag', array( 'Akismet', 'load_form_js_via_filter' ), 10, 4 ); 81 82 add_action( 'akismet_scheduled_delete', array( 'Akismet', 'delete_old_comments' ) ); 83 add_action( 'akismet_scheduled_delete', array( 'Akismet', 'delete_old_comments_meta' ) ); 84 add_action( 'akismet_scheduled_delete', array( 'Akismet', 'delete_orphaned_commentmeta' ) ); 85 add_action( 'akismet_schedule_cron_recheck', array( 'Akismet', 'cron_recheck' ) ); 86 87 add_action( 'akismet_email_fallback', array( 'Akismet', 'email_fallback' ), 10, 3 ); 88 add_action( 'akismet_approval_fallback', array( 'Akismet', 'approval_fallback' ), 10, 3 ); 89 90 add_action( 'comment_form', array( 'Akismet', 'add_comment_nonce' ), 1 ); 91 add_action( 'comment_form', array( 'Akismet', 'output_custom_form_fields' ) ); 92 add_filter( 'script_loader_tag', array( 'Akismet', 'set_form_js_async' ), 10, 3 ); 93 94 add_filter( 'notify_moderator', array( 'Akismet', 'disable_emails_if_unreachable' ), 1000, 2 ); 95 add_filter( 'notify_post_author', array( 'Akismet', 'disable_emails_if_unreachable' ), 1000, 2 ); 96 97 add_filter( 'pre_comment_approved', array( 'Akismet', 'last_comment_status' ), 10, 2 ); 98 99 add_action( 'transition_comment_status', array( 'Akismet', 'transition_comment_status' ), 10, 3 ); 100 101 // Run this early in the pingback call, before doing a remote fetch of the source uri 102 add_action( 'xmlrpc_call', array( 'Akismet', 'pre_check_pingback' ), 10, 3 ); 103 104 // Jetpack compatibility 105 add_filter( 'jetpack_options_whitelist', array( 'Akismet', 'add_to_jetpack_options_whitelist' ) ); 106 add_filter( 'jetpack_contact_form_html', array( 'Akismet', 'inject_custom_form_fields' ) ); 107 add_filter( 'jetpack_contact_form_akismet_values', array( 'Akismet', 'prepare_custom_form_values' ) ); 108 109 // Gravity Forms 110 add_filter( 'gform_get_form_filter', array( 'Akismet', 'inject_custom_form_fields' ) ); 111 add_filter( 'gform_akismet_fields', array( 'Akismet', 'prepare_custom_form_values' ) ); 112 113 // Contact Form 7 114 add_filter( 'wpcf7_form_elements', array( 'Akismet', 'append_custom_form_fields' ) ); 115 add_filter( 'wpcf7_akismet_parameters', array( 'Akismet', 'prepare_custom_form_values' ) ); 116 117 // Formidable Forms 118 add_filter( 'frm_filter_final_form', array( 'Akismet', 'inject_custom_form_fields' ) ); 119 add_filter( 'frm_akismet_values', array( 'Akismet', 'prepare_custom_form_values' ) ); 120 121 // Fluent Forms 122 /* 123 * The Fluent Forms hook names were updated in version 5.0.0. The last version that supported 124 * the original hook names was 4.3.25, and version 4.3.25 was tested up to WordPress version 6.1. 125 * 126 * The legacy hooks are fired before the new hooks. See 127 * https://github.com/fluentform/fluentform/commit/cc45341afcae400f217470a7bbfb15efdd80454f 128 * 129 * The legacy Fluent Forms hooks will be removed when Akismet no longer supports WordPress version 6.1. 130 * This will provide compatibility with previous versions of Fluent Forms for a reasonable amount of time. 131 */ 132 add_filter( 'fluentform_form_element_start', array( 'Akismet', 'output_custom_form_fields' ) ); 133 add_filter( 'fluentform_akismet_fields', array( 'Akismet', 'prepare_custom_form_values' ), 10, 2 ); 134 // Current Fluent Form hooks. 135 add_filter( 'fluentform/form_element_start', array( 'Akismet', 'output_custom_form_fields' ) ); 136 add_filter( 'fluentform/akismet_fields', array( 'Akismet', 'prepare_custom_form_values' ), 10, 2 ); 137 138 add_action( 'update_option_wordpress_api_key', array( 'Akismet', 'updated_option' ), 10, 2 ); 139 add_action( 'add_option_wordpress_api_key', array( 'Akismet', 'added_option' ), 10, 2 ); 140 141 add_action( 'comment_form_after', array( 'Akismet', 'display_comment_form_privacy_notice' ) ); 142 } 143 144 public static function get_api_key() { 145 return apply_filters( 'akismet_get_api_key', defined( 'WPCOM_API_KEY' ) ? constant( 'WPCOM_API_KEY' ) : get_option( 'wordpress_api_key' ) ); 146 } 147 148 /** 149 * Exchange the API key for a token that can only be used to access stats pages. 150 * 151 * @return string 152 */ 153 public static function get_access_token() { 154 static $access_token = null; 155 156 if ( is_null( $access_token ) ) { 157 $request_args = array( 'api_key' => self::get_api_key() ); 158 159 $request_args = apply_filters( 'akismet_request_args', $request_args, 'token' ); 160 161 $response = self::http_post( self::build_query( $request_args ), 'token' ); 162 163 $access_token = $response[1]; 164 } 165 166 return $access_token; 167 } 168 169 public static function check_key_status( $key, $ip = null ) { 170 $request_args = array( 171 'key' => $key, 172 'blog' => get_option( 'home' ), 173 ); 174 175 $request_args = apply_filters( 'akismet_request_args', $request_args, 'verify-key' ); 176 177 return self::http_post( self::build_query( $request_args ), 'verify-key', $ip ); 178 } 179 180 public static function verify_key( $key, $ip = null ) { 181 // Shortcut for obviously invalid keys. 182 if ( strlen( $key ) != 12 ) { 183 return 'invalid'; 184 } 185 186 $response = self::check_key_status( $key, $ip ); 187 188 if ( $response[1] != 'valid' && $response[1] != 'invalid' ) { 189 return 'failed'; 190 } 191 192 return $response[1]; 193 } 194 195 public static function deactivate_key( $key ) { 196 $request_args = array( 197 'key' => $key, 198 'blog' => get_option( 'home' ), 199 ); 200 201 $request_args = apply_filters( 'akismet_request_args', $request_args, 'deactivate' ); 202 203 $response = self::http_post( self::build_query( $request_args ), 'deactivate' ); 204 205 if ( $response[1] != 'deactivated' ) { 206 return 'failed'; 207 } 208 209 return $response[1]; 210 } 211 212 /** 213 * Add the akismet option to the Jetpack options management whitelist. 214 * 215 * @param array $options The list of whitelisted option names. 216 * @return array The updated whitelist 217 */ 218 public static function add_to_jetpack_options_whitelist( $options ) { 219 $options[] = 'wordpress_api_key'; 220 return $options; 221 } 222 223 /** 224 * When the akismet option is updated, run the registration call. 225 * 226 * This should only be run when the option is updated from the Jetpack/WP.com 227 * API call, and only if the new key is different than the old key. 228 * 229 * @param mixed $old_value The old option value. 230 * @param mixed $value The new option value. 231 */ 232 public static function updated_option( $old_value, $value ) { 233 // Not an API call 234 if ( ! class_exists( 'WPCOM_JSON_API_Update_Option_Endpoint' ) ) { 235 return; 236 } 237 // Only run the registration if the old key is different. 238 if ( $old_value !== $value ) { 239 self::verify_key( $value ); 240 } 241 } 242 243 /** 244 * Treat the creation of an API key the same as updating the API key to a new value. 245 * 246 * @param mixed $option_name Will always be "wordpress_api_key", until something else hooks in here. 247 * @param mixed $value The option value. 248 */ 249 public static function added_option( $option_name, $value ) { 250 if ( 'wordpress_api_key' === $option_name ) { 251 return self::updated_option( '', $value ); 252 } 253 } 254 255 public static function rest_auto_check_comment( $commentdata ) { 256 return self::auto_check_comment( $commentdata, 'rest_api' ); 257 } 258 259 /** 260 * Check a comment for spam. 261 * 262 * @param array $commentdata 263 * @param string $context What kind of request triggered this comment check? Possible values are 'default', 'rest_api', and 'xml-rpc'. 264 * @return array|WP_Error Either the $commentdata array with additional entries related to its spam status 265 * or a WP_Error, if it's a REST API request and the comment should be discarded. 266 */ 267 public static function auto_check_comment( $commentdata, $context = 'default' ) { 268 // If no key is configured, then there's no point in doing any of this. 269 if ( ! self::get_api_key() ) { 270 return $commentdata; 271 } 272 273 if ( ! isset( $commentdata['comment_meta'] ) ) { 274 $commentdata['comment_meta'] = array(); 275 } 276 277 self::$last_comment_result = null; 278 279 // Skip the Akismet check if the comment matches the Disallowed Keys list. 280 if ( function_exists( 'wp_check_comment_disallowed_list' ) ) { 281 $comment_author = isset( $commentdata['comment_author'] ) ? $commentdata['comment_author'] : ''; 282 $comment_author_email = isset( $commentdata['comment_author_email'] ) ? $commentdata['comment_author_email'] : ''; 283 $comment_author_url = isset( $commentdata['comment_author_url'] ) ? $commentdata['comment_author_url'] : ''; 284 $comment_content = isset( $commentdata['comment_content'] ) ? $commentdata['comment_content'] : ''; 285 $comment_author_ip = isset( $commentdata['comment_author_IP'] ) ? $commentdata['comment_author_IP'] : ''; 286 $comment_agent = isset( $commentdata['comment_agent'] ) ? $commentdata['comment_agent'] : ''; 287 288 if ( wp_check_comment_disallowed_list( $comment_author, $comment_author_email, $comment_author_url, $comment_content, $comment_author_ip, $comment_agent ) ) { 289 $commentdata['akismet_result'] = 'skipped'; 290 $commentdata['comment_meta']['akismet_result'] = 'skipped'; 291 292 $commentdata['akismet_skipped_microtime'] = microtime( true ); 293 $commentdata['comment_meta']['akismet_skipped_microtime'] = $commentdata['akismet_skipped_microtime']; 294 295 self::set_last_comment( $commentdata ); 296 297 return $commentdata; 298 } 299 } 300 301 $comment = $commentdata; 302 303 $comment['user_ip'] = self::get_ip_address(); 304 $comment['user_agent'] = self::get_user_agent(); 305 $comment['referrer'] = self::get_referer(); 306 $comment['blog'] = get_option( 'home' ); 307 $comment['blog_lang'] = get_locale(); 308 $comment['blog_charset'] = get_option( 'blog_charset' ); 309 $comment['permalink'] = get_permalink( $comment['comment_post_ID'] ); 310 311 if ( ! empty( $comment['user_ID'] ) ) { 312 $comment['user_role'] = self::get_user_roles( $comment['user_ID'] ); 313 } 314 315 /** See filter documentation in init_hooks(). */ 316 $akismet_nonce_option = apply_filters( 'akismet_comment_nonce', get_option( 'akismet_comment_nonce' ) ); 317 $comment['akismet_comment_nonce'] = 'inactive'; 318 if ( $akismet_nonce_option == 'true' || $akismet_nonce_option == '' ) { 319 $comment['akismet_comment_nonce'] = 'failed'; 320 if ( isset( $_POST['akismet_comment_nonce'] ) && wp_verify_nonce( $_POST['akismet_comment_nonce'], 'akismet_comment_nonce_' . $comment['comment_post_ID'] ) ) { 321 $comment['akismet_comment_nonce'] = 'passed'; 322 } 323 324 // comment reply in wp-admin 325 if ( isset( $_POST['_ajax_nonce-replyto-comment'] ) && check_ajax_referer( 'replyto-comment', '_ajax_nonce-replyto-comment' ) ) { 326 $comment['akismet_comment_nonce'] = 'passed'; 327 } 328 } 329 330 if ( self::is_test_mode() ) { 331 $comment['is_test'] = 'true'; 332 } 333 334 foreach ( $_POST as $key => $value ) { 335 if ( is_string( $value ) ) { 336 $comment[ "POST_{$key}" ] = $value; 337 } 338 } 339 340 foreach ( $_SERVER as $key => $value ) { 341 if ( ! is_string( $value ) ) { 342 continue; 343 } 344 345 if ( preg_match( '/^HTTP_COOKIE/', $key ) ) { 346 continue; 347 } 348 349 // Send any potentially useful $_SERVER vars, but avoid sending junk we don't need. 350 if ( preg_match( '/^(HTTP_|REMOTE_ADDR|REQUEST_URI|DOCUMENT_URI)/', $key ) ) { 351 $comment[ "$key" ] = $value; 352 } 353 } 354 355 $post = get_post( $comment['comment_post_ID'] ); 356 357 if ( ! is_null( $post ) ) { 358 // $post can technically be null, although in the past, it's always been an indicator of another plugin interfering. 359 $comment['comment_post_modified_gmt'] = $post->post_modified_gmt; 360 361 // Tags and categories are important context in which to consider the comment. 362 $comment['comment_context'] = array(); 363 364 $tag_names = wp_get_post_tags( $post->ID, array( 'fields' => 'names' ) ); 365 366 if ( $tag_names && ! is_wp_error( $tag_names ) ) { 367 foreach ( $tag_names as $tag_name ) { 368 $comment['comment_context'][] = $tag_name; 369 } 370 } 371 372 $category_names = wp_get_post_categories( $post->ID, array( 'fields' => 'names' ) ); 373 374 if ( $category_names && ! is_wp_error( $category_names ) ) { 375 foreach ( $category_names as $category_name ) { 376 $comment['comment_context'][] = $category_name; 377 } 378 } 379 } 380 381 // Set the webhook callback URL. The Akismet servers may make a request to this URL 382 // if a comment's spam status changes. 383 $comment['callback'] = get_rest_url( null, 'akismet/v1/webhook' ); 384 385 /** 386 * Filter the data that is used to generate the request body for the API call. 387 * 388 * @since 5.3.1 389 * 390 * @param array $comment An array of request data. 391 * @param string $endpoint The API endpoint being requested. 392 */ 393 $comment = apply_filters( 'akismet_request_args', $comment, 'comment-check' ); 394 395 $response = self::http_post( self::build_query( $comment ), 'comment-check' ); 396 397 do_action( 'akismet_comment_check_response', $response ); 398 399 $commentdata['comment_as_submitted'] = array_intersect_key( $comment, self::$comment_as_submitted_allowed_keys ); 400 401 // Also include any form fields we inject into the comment form, like ak_js 402 foreach ( $_POST as $key => $value ) { 403 if ( is_string( $value ) && strpos( $key, 'ak_' ) === 0 ) { 404 $commentdata['comment_as_submitted'][ 'POST_' . $key ] = $value; 405 } 406 } 407 408 $commentdata['akismet_result'] = $response[1]; 409 410 if ( 'true' === $response[1] || 'false' === $response[1] ) { 411 $commentdata['comment_meta']['akismet_result'] = $response[1]; 412 } else { 413 $commentdata['comment_meta']['akismet_error'] = time(); 414 } 415 416 if ( isset( $response[0]['x-akismet-pro-tip'] ) ) { 417 $commentdata['akismet_pro_tip'] = $response[0]['x-akismet-pro-tip']; 418 $commentdata['comment_meta']['akismet_pro_tip'] = $response[0]['x-akismet-pro-tip']; 419 } 420 421 if ( isset( $response[0]['x-akismet-guid'] ) ) { 422 $commentdata['akismet_guid'] = $response[0]['x-akismet-guid']; 423 $commentdata['comment_meta']['akismet_guid'] = $response[0]['x-akismet-guid']; 424 425 if ( 'false' === $response[1] ) { 426 // If Akismet has indicated that there is more processing to be done before this comment 427 // can be fully classified, delay moderation emails until that processing is complete. 428 if ( isset( $response[0]['X-akismet-recheck-after'] ) ) { 429 // Prevent this comment from reaching Active status (keep in Pending) until 430 // it's finished being checked. 431 $commentdata['comment_approved'] = '0'; 432 self::$last_comment_result = '0'; 433 434 // Indicate that we should schedule a fallback so that if the site never receives a 435 // followup from Akismet, the emails will still be sent. We don't schedule it here 436 // because we don't yet have the comment ID. Add an extra minute to ensure that the 437 // fallback email isn't sent while the recheck or webhook call is happening. 438 $delay = $response[0]['X-akismet-recheck-after'] * 2; 439 440 $commentdata['comment_meta']['akismet_schedule_approval_fallback'] = $delay; 441 442 // If this commentmeta is present, we'll prevent the moderation email from sending once. 443 $commentdata['comment_meta']['akismet_delay_moderation_email'] = true; 444 445 self::log( 'Delaying moderation email for comment from ' . $commentdata['comment_author'] . ' for ' . $delay . ' seconds' ); 446 447 $commentdata['comment_meta']['akismet_schedule_email_fallback'] = $delay; 448 } 449 } 450 } 451 452 $commentdata['comment_meta']['akismet_as_submitted'] = $commentdata['comment_as_submitted']; 453 454 if ( isset( $response[0]['x-akismet-error'] ) ) { 455 // An error occurred that we anticipated (like a suspended key) and want the user to act on. 456 // Send to moderation. 457 self::$last_comment_result = '0'; 458 } elseif ( 'true' == $response[1] ) { 459 // akismet_spam_count will be incremented later by comment_is_spam() 460 self::$last_comment_result = 'spam'; 461 462 $discard = ( isset( $commentdata['akismet_pro_tip'] ) && $commentdata['akismet_pro_tip'] === 'discard' && self::allow_discard() ); 463 464 do_action( 'akismet_spam_caught', $discard ); 465 466 if ( $discard ) { 467 // The spam is obvious, so we're bailing out early. 468 // akismet_result_spam() won't be called so bump the counter here 469 if ( $incr = apply_filters( 'akismet_spam_count_incr', 1 ) ) { 470 update_option( 'akismet_spam_count', get_option( 'akismet_spam_count' ) + $incr ); 471 } 472 473 if ( 'rest_api' === $context ) { 474 return new WP_Error( 'akismet_rest_comment_discarded', __( 'Comment discarded.', 'akismet' ) ); 475 } elseif ( 'xml-rpc' === $context ) { 476 // If this is a pingback that we're pre-checking, the discard behavior is the same as the normal spam response behavior. 477 return $commentdata; 478 } else { 479 // Redirect back to the previous page, or failing that, the post permalink, or failing that, the homepage of the blog. 480 $redirect_to = isset( $_SERVER['HTTP_REFERER'] ) ? $_SERVER['HTTP_REFERER'] : ( $post ? get_permalink( $post ) : home_url() ); 481 wp_safe_redirect( esc_url_raw( $redirect_to ) ); 482 die(); 483 } 484 } elseif ( 'rest_api' === $context ) { 485 // The way the REST API structures its calls, we can set the comment_approved value right away. 486 $commentdata['comment_approved'] = 'spam'; 487 } 488 } 489 490 // if the response is neither true nor false, hold the comment for moderation and schedule a recheck 491 if ( 'true' != $response[1] && 'false' != $response[1] ) { 492 if ( ! current_user_can( 'moderate_comments' ) ) { 493 // Comment status should be moderated 494 self::$last_comment_result = '0'; 495 } 496 497 $commentdata['comment_meta']['akismet_delay_moderation_email'] = true; 498 499 if ( ! wp_next_scheduled( 'akismet_schedule_cron_recheck' ) ) { 500 wp_schedule_single_event( time() + 1200, 'akismet_schedule_cron_recheck' ); 501 do_action( 'akismet_scheduled_recheck', 'invalid-response-' . $response[1] ); 502 } 503 } 504 505 // Delete old comments daily 506 if ( ! wp_next_scheduled( 'akismet_scheduled_delete' ) ) { 507 wp_schedule_event( time(), 'daily', 'akismet_scheduled_delete' ); 508 } 509 510 self::set_last_comment( $commentdata ); 511 self::fix_scheduled_recheck(); 512 513 return $commentdata; 514 } 515 516 public static function get_last_comment() { 517 return self::$last_comment; 518 } 519 520 public static function set_last_comment( $comment ) { 521 if ( is_null( $comment ) ) { 522 // This never happens in our code. 523 self::$last_comment = null; 524 } else { 525 // We filter it here so that it matches the filtered comment data that we'll have to compare against later. 526 // wp_filter_comment expects comment_author_IP 527 self::$last_comment = wp_filter_comment( 528 array_merge( 529 array( 'comment_author_IP' => self::get_ip_address() ), 530 $comment 531 ) 532 ); 533 } 534 } 535 536 // this fires on wp_insert_comment. we can't update comment_meta when auto_check_comment() runs 537 // because we don't know the comment ID at that point. 538 public static function auto_check_update_meta( $id, $comment ) { 539 // wp_insert_comment() might be called in other contexts, so make sure this is the same comment 540 // as was checked by auto_check_comment 541 if ( is_object( $comment ) && ! empty( self::$last_comment ) && is_array( self::$last_comment ) ) { 542 if ( self::matches_last_comment_by_id( $id ) ) { 543 // normal result: true or false 544 if ( isset( self::$last_comment['akismet_result'] ) && self::$last_comment['akismet_result'] == 'true' ) { 545 self::update_comment_history( $comment->comment_ID, '', 'check-spam' ); 546 if ( $comment->comment_approved != 'spam' ) { 547 self::update_comment_history( 548 $comment->comment_ID, 549 '', 550 'status-changed-' . $comment->comment_approved 551 ); 552 } 553 } elseif ( isset( self::$last_comment['akismet_result'] ) && self::$last_comment['akismet_result'] == 'false' ) { 554 if ( get_comment_meta( $comment->comment_ID, 'akismet_schedule_approval_fallback', true ) ) { 555 self::update_comment_history( $comment->comment_ID, '', 'check-ham-pending' ); 556 } else { 557 self::update_comment_history( $comment->comment_ID, '', 'check-ham' ); 558 } 559 560 // Status could be spam or trash, depending on the WP version and whether this change applies: 561 // https://core.trac.wordpress.org/changeset/34726 562 if ( $comment->comment_approved == 'spam' || $comment->comment_approved == 'trash' ) { 563 if ( function_exists( 'wp_check_comment_disallowed_list' ) ) { 564 if ( wp_check_comment_disallowed_list( $comment->comment_author, $comment->comment_author_email, $comment->comment_author_url, $comment->comment_content, $comment->comment_author_IP, $comment->comment_agent ) ) { 565 self::update_comment_history( $comment->comment_ID, '', 'wp-disallowed' ); 566 } else { 567 self::update_comment_history( $comment->comment_ID, '', 'status-changed-' . $comment->comment_approved ); 568 } 569 } else { 570 self::update_comment_history( $comment->comment_ID, '', 'status-changed-' . $comment->comment_approved ); 571 } 572 } 573 } elseif ( isset( self::$last_comment['akismet_result'] ) && 'skipped' == self::$last_comment['akismet_result'] ) { 574 // The comment wasn't sent to Akismet because it matched the disallowed comment keys. 575 self::update_comment_history( $comment->comment_ID, '', 'wp-disallowed' ); 576 self::update_comment_history( $comment->comment_ID, '', 'akismet-skipped-disallowed' ); 577 } else if ( ! isset( self::$last_comment['akismet_result'] ) ) { 578 // Add a generic skipped history item. 579 self::update_comment_history( $comment->comment_ID, '', 'akismet-skipped' ); 580 } else { 581 // abnormal result: error 582 self::update_comment_history( 583 $comment->comment_ID, 584 '', 585 'check-error', 586 array( 'response' => substr( self::$last_comment['akismet_result'], 0, 50 ) ) 587 ); 588 } 589 } 590 } 591 } 592 593 /** 594 * After the comment has been inserted, we have access to the comment ID. Now, we can 595 * schedule the fallback moderation/notification emails using the comment ID instead 596 * of relying on a lookup of the GUID in the commentmeta table. 597 * 598 * @param int $id The comment ID. 599 * @param object $comment The comment object. 600 */ 601 public static function schedule_email_fallback( $id, $comment ) { 602 self::log( 'Checking whether to schedule_email_fallback for comment #' . $id ); 603 604 // If the moderation/notification emails for this comment were delayed 605 $email_delay = get_comment_meta( $id, 'akismet_schedule_email_fallback', true ); 606 607 if ( $email_delay ) { 608 delete_comment_meta( $id, 'akismet_schedule_email_fallback' ); 609 610 wp_schedule_single_event( time() + $email_delay, 'akismet_email_fallback', array( $id ) ); 611 612 self::log( 'Scheduled email fallback for ' . ( time() + $email_delay ) . ' for comment #' . $id ); 613 } else { 614 self::log( 'No need to schedule_email_fallback for comment #' . $id ); 615 } 616 } 617 618 /** 619 * Send out the notification emails if they were previously delayed while waiting 620 * for a recheck or webhook. 621 * 622 * @param int $comment_ID The comment ID. 623 */ 624 public static function email_fallback( $comment_id ) { 625 self::log( 'In email fallback for comment #' . $comment_id ); 626 627 if ( get_comment_meta( $comment_id, 'akismet_delayed_moderation_email', true ) ) { 628 self::log( 'Triggering notification emails for comment #' . $comment_id . '. They will be sent if comment is not spam.' ); 629 630 delete_comment_meta( $comment_id, 'akismet_delayed_moderation_email' ); 631 wp_new_comment_notify_moderator( $comment_id ); 632 wp_new_comment_notify_postauthor( $comment_id ); 633 } else { 634 self::log( 'No need to send fallback email for comment #' . $comment_id ); 635 } 636 637 delete_comment_meta( $comment_id, 'akismet_delay_moderation_email' ); 638 } 639 640 /** 641 * After the comment has been inserted, we have access to the comment ID. Now, we can 642 * schedule the fallback moderation/notification emails using the comment ID instead 643 * of relying on a lookup of the GUID in the commentmeta table. 644 * 645 * @param int $id The comment ID. 646 * @param object $comment The comment object. 647 */ 648 public static function schedule_approval_fallback( $id, $comment ) { 649 self::log( 'Checking whether to schedule_approval_fallback for comment #' . $id ); 650 651 // If the moderation/notification emails for this comment were delayed 652 $approval_delay = get_comment_meta( $id, 'akismet_schedule_approval_fallback', true ); 653 654 if ( $approval_delay ) { 655 delete_comment_meta( $id, 'akismet_schedule_approval_fallback' ); 656 657 wp_schedule_single_event( time() + $approval_delay, 'akismet_approval_fallback', array( $id ) ); 658 659 self::log( 'Scheduled approval fallback for ' . ( time() + $approval_delay ) . ' for comment #' . $id ); 660 } else { 661 self::log( 'No need to schedule_approval_fallback for comment #' . $id ); 662 } 663 } 664 665 /** 666 * If no other process has approved or spammed this comment since it was put in pending, approve it. 667 * 668 * @param int $comment_ID The comment ID. 669 */ 670 public static function approval_fallback( $comment_id ) { 671 self::log( 'In approval fallback for comment #' . $comment_id ); 672 673 if ( wp_get_comment_status( $comment_id ) == 'unapproved' ) { 674 if ( self::last_comment_status_change_came_from_akismet( $comment_id ) ) { 675 $comment = get_comment( $comment_id ); 676 677 if ( ! $comment ) { 678 self::log( 'Comment #' . $comment_id . ' no longer exists.' ); 679 } else if ( check_comment( $comment->comment_author, $comment->comment_author_email, $comment->comment_author_url, $comment->comment_content, $comment->comment_author_IP, $comment->comment_agent, $comment->comment_type ) ) { 680 self::log( 'Approving comment #' . $comment_id ); 681 682 wp_set_comment_status( $comment_id, 1 ); 683 } else { 684 self::log( 'Not approving comment #' . $comment_id . ' because it does not pass check_comment()' ); 685 } 686 687 self::update_comment_history( $comment->comment_ID, '', 'check-ham' ); 688 } else { 689 self::log( 'No need to fallback approve comment #' . $comment_id . ' because it was not last modified by Akismet.' ); 690 691 $history = self::get_comment_history( $comment_id ); 692 693 if ( ! empty( $history ) ) { 694 $most_recent_history_event = $history[0]; 695 696 error_log( 'Comment history: ' . print_r( $history, true ) ); 697 } 698 } 699 } else { 700 self::log( 'No need to fallback approve comment #' . $comment_id . ' because it is not pending.' ); 701 } 702 } 703 704 public static function delete_old_comments() { 705 global $wpdb; 706 707 /** 708 * Determines how many comments will be deleted in each batch. 709 * 710 * @param int The default, as defined by AKISMET_DELETE_LIMIT. 711 */ 712 $delete_limit = apply_filters( 'akismet_delete_comment_limit', defined( 'AKISMET_DELETE_LIMIT' ) ? AKISMET_DELETE_LIMIT : 10000 ); 713 $delete_limit = max( 1, intval( $delete_limit ) ); 714 715 /** 716 * Determines how many days a comment will be left in the Spam queue before being deleted. 717 * 718 * @param int The default number of days. 719 */ 720 $delete_interval = apply_filters( 'akismet_delete_comment_interval', 15 ); 721 $delete_interval = max( 1, intval( $delete_interval ) ); 722 723 while ( $comment_ids = $wpdb->get_col( $wpdb->prepare( "SELECT comment_id FROM {$wpdb->comments} WHERE DATE_SUB(NOW(), INTERVAL %d DAY) > comment_date_gmt AND comment_approved = 'spam' LIMIT %d", $delete_interval, $delete_limit ) ) ) { 724 if ( empty( $comment_ids ) ) { 725 return; 726 } 727 728 $wpdb->queries = array(); 729 730 $comments = array(); 731 732 foreach ( $comment_ids as $comment_id ) { 733 $comments[ $comment_id ] = get_comment( $comment_id ); 734 735 do_action( 'delete_comment', $comment_id, $comments[ $comment_id ] ); 736 do_action( 'akismet_batch_delete_count', __FUNCTION__ ); 737 } 738 739 // Prepared as strings since comment_id is an unsigned BIGINT, and using %d will constrain the value to the maximum signed BIGINT. 740 $format_string = implode( ', ', array_fill( 0, is_countable( $comment_ids ) ? count( $comment_ids ) : 0, '%s' ) ); 741 742 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->comments} WHERE comment_id IN ( " . $format_string . ' )', $comment_ids ) ); 743 $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->commentmeta} WHERE comment_id IN ( " . $format_string . ' )', $comment_ids ) ); 744 745 foreach ( $comment_ids as $comment_id ) { 746 do_action( 'deleted_comment', $comment_id, $comments[ $comment_id ] ); 747 unset( $comments[ $comment_id ] ); 748 } 749 750 clean_comment_cache( $comment_ids ); 751 do_action( 'akismet_delete_comment_batch', is_countable( $comment_ids ) ? count( $comment_ids ) : 0 ); 752 } 753 754 if ( apply_filters( 'akismet_optimize_table', ( mt_rand( 1, 5000 ) == 11 ), $wpdb->comments ) ) { // lucky number 755 $wpdb->query( "OPTIMIZE TABLE {$wpdb->comments}" ); 756 } 757 } 758 759 public static function delete_old_comments_meta() { 760 global $wpdb; 761 762 $interval = apply_filters( 'akismet_delete_commentmeta_interval', 15 ); 763 764 // enforce a minimum of 1 day 765 $interval = absint( $interval ); 766 if ( $interval < 1 ) { 767 $interval = 1; 768 } 769 770 // akismet_as_submitted meta values are large, so expire them 771 // after $interval days regardless of the comment status 772 while ( $comment_ids = $wpdb->get_col( $wpdb->prepare( "SELECT m.comment_id FROM {$wpdb->commentmeta} as m INNER JOIN {$wpdb->comments} as c USING(comment_id) WHERE m.meta_key = 'akismet_as_submitted' AND DATE_SUB(NOW(), INTERVAL %d DAY) > c.comment_date_gmt LIMIT 10000", $interval ) ) ) { 773 if ( empty( $comment_ids ) ) { 774 return; 775 } 776 777 $wpdb->queries = array(); 778 779 foreach ( $comment_ids as $comment_id ) { 780 delete_comment_meta( $comment_id, 'akismet_as_submitted' ); 781 do_action( 'akismet_batch_delete_count', __FUNCTION__ ); 782 } 783 784 do_action( 'akismet_delete_commentmeta_batch', is_countable( $comment_ids ) ? count( $comment_ids ) : 0 ); 785 } 786 787 if ( apply_filters( 'akismet_optimize_table', ( mt_rand( 1, 5000 ) == 11 ), $wpdb->commentmeta ) ) { // lucky number 788 $wpdb->query( "OPTIMIZE TABLE {$wpdb->commentmeta}" ); 789 } 790 } 791 792 // Clear out comments meta that no longer have corresponding comments in the database 793 public static function delete_orphaned_commentmeta() { 794 global $wpdb; 795 796 $last_meta_id = 0; 797 $start_time = isset( $_SERVER['REQUEST_TIME_FLOAT'] ) ? $_SERVER['REQUEST_TIME_FLOAT'] : microtime( true ); 798 $max_exec_time = max( ini_get( 'max_execution_time' ) - 5, 3 ); 799 800 while ( $commentmeta_results = $wpdb->get_results( $wpdb->prepare( "SELECT m.meta_id, m.comment_id, m.meta_key FROM {$wpdb->commentmeta} as m LEFT JOIN {$wpdb->comments} as c USING(comment_id) WHERE c.comment_id IS NULL AND m.meta_id > %d ORDER BY m.meta_id LIMIT 1000", $last_meta_id ) ) ) { 801 if ( empty( $commentmeta_results ) ) { 802 return; 803 } 804 805 $wpdb->queries = array(); 806 807 $commentmeta_deleted = 0; 808 809 foreach ( $commentmeta_results as $commentmeta ) { 810 if ( 'akismet_' == substr( $commentmeta->meta_key, 0, 8 ) ) { 811 delete_comment_meta( $commentmeta->comment_id, $commentmeta->meta_key ); 812 do_action( 'akismet_batch_delete_count', __FUNCTION__ ); 813 ++$commentmeta_deleted; 814 } 815 816 $last_meta_id = $commentmeta->meta_id; 817 } 818 819 do_action( 'akismet_delete_commentmeta_batch', $commentmeta_deleted ); 820 821 // If we're getting close to max_execution_time, quit for this round. 822 if ( microtime( true ) - $start_time > $max_exec_time ) { 823 return; 824 } 825 } 826 827 if ( apply_filters( 'akismet_optimize_table', ( mt_rand( 1, 5000 ) == 11 ), $wpdb->commentmeta ) ) { // lucky number 828 $wpdb->query( "OPTIMIZE TABLE {$wpdb->commentmeta}" ); 829 } 830 } 831 832 // how many approved comments does this author have? 833 public static function get_user_comments_approved( $user_id, $comment_author_email, $comment_author, $comment_author_url ) { 834 global $wpdb; 835 836 /** 837 * Which comment types should be ignored when counting a user's approved comments? 838 * 839 * Some plugins add entries to the comments table that are not actual 840 * comments that could have been checked by Akismet. Allow these comments 841 * to be excluded from the "approved comment count" query in order to 842 * avoid artificially inflating the approved comment count. 843 * 844 * @param array $comment_types An array of comment types that won't be considered 845 * when counting a user's approved comments. 846 * 847 * @since 4.2.2 848 */ 849 $excluded_comment_types = apply_filters( 'akismet_excluded_comment_types', array() ); 850 851 $comment_type_where = ''; 852 853 if ( is_array( $excluded_comment_types ) && ! empty( $excluded_comment_types ) ) { 854 $excluded_comment_types = array_unique( $excluded_comment_types ); 855 856 foreach ( $excluded_comment_types as $excluded_comment_type ) { 857 $comment_type_where .= $wpdb->prepare( ' AND comment_type <> %s ', $excluded_comment_type ); 858 } 859 } 860 861 if ( ! empty( $user_id ) ) { 862 return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->comments} WHERE user_id = %d AND comment_approved = 1" . $comment_type_where, $user_id ) ); 863 } 864 865 if ( ! empty( $comment_author_email ) ) { 866 return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_author_email = %s AND comment_author = %s AND comment_author_url = %s AND comment_approved = 1" . $comment_type_where, $comment_author_email, $comment_author, $comment_author_url ) ); 867 } 868 869 return 0; 870 } 871 872 /** 873 * Get the full comment history for a given comment, as an array in reverse chronological order. 874 * Each entry will have an 'event', a 'time', and possibly a 'message' member (if the entry is old enough). 875 * Some entries will also have a 'user' or 'meta' member. 876 * 877 * @param int $comment_id The relevant comment ID. 878 * @return array|bool An array of history events, or false if there is no history. 879 */ 880 public static function get_comment_history( $comment_id ) { 881 $history = get_comment_meta( $comment_id, 'akismet_history', false ); 882 if ( empty( $history ) || empty( $history[0] ) ) { 883 return false; 884 } 885 886 /* 887 // To see all variants when testing. 888 $history[] = array( 'time' => 445856401, 'message' => 'Old versions of Akismet stored the message as a literal string in the commentmeta.', 'event' => null ); 889 $history[] = array( 'time' => 445856402, 'event' => 'recheck-spam' ); 890 $history[] = array( 'time' => 445856403, 'event' => 'check-spam' ); 891 $history[] = array( 'time' => 445856404, 'event' => 'recheck-ham' ); 892 $history[] = array( 'time' => 445856405, 'event' => 'check-ham' ); 893 $history[] = array( 'time' => 445856405, 'event' => 'check-ham-pending' ); 894 $history[] = array( 'time' => 445856406, 'event' => 'wp-blacklisted' ); 895 $history[] = array( 'time' => 445856406, 'event' => 'wp-disallowed' ); 896 $history[] = array( 'time' => 445856407, 'event' => 'report-spam' ); 897 $history[] = array( 'time' => 445856408, 'event' => 'report-spam', 'user' => 'sam' ); 898 $history[] = array( 'message' => 'sam reported this comment as spam (hardcoded message).', 'time' => 445856400, 'event' => 'report-spam', 'user' => 'sam' ); 899 $history[] = array( 'time' => 445856409, 'event' => 'report-ham', 'user' => 'sam' ); 900 $history[] = array( 'message' => 'sam reported this comment as ham (hardcoded message).', 'time' => 445856400, 'event' => 'report-ham', 'user' => 'sam' ); // 901 $history[] = array( 'time' => 445856410, 'event' => 'cron-retry-spam' ); 902 $history[] = array( 'time' => 445856411, 'event' => 'cron-retry-ham' ); 903 $history[] = array( 'time' => 445856412, 'event' => 'check-error' ); // 904 $history[] = array( 'time' => 445856413, 'event' => 'check-error', 'meta' => array( 'response' => 'The server was taking a nap.' ) ); 905 $history[] = array( 'time' => 445856414, 'event' => 'recheck-error' ); // Should not generate a message. 906 $history[] = array( 'time' => 445856415, 'event' => 'recheck-error', 'meta' => array( 'response' => 'The server was taking a nap.' ) ); 907 $history[] = array( 'time' => 445856416, 'event' => 'status-changedtrash' ); 908 $history[] = array( 'time' => 445856417, 'event' => 'status-changedspam' ); 909 $history[] = array( 'time' => 445856418, 'event' => 'status-changedhold' ); 910 $history[] = array( 'time' => 445856419, 'event' => 'status-changedapprove' ); 911 $history[] = array( 'time' => 445856420, 'event' => 'status-changed-trash' ); 912 $history[] = array( 'time' => 445856421, 'event' => 'status-changed-spam' ); 913 $history[] = array( 'time' => 445856422, 'event' => 'status-changed-hold' ); 914 $history[] = array( 'time' => 445856423, 'event' => 'status-changed-approve' ); 915 $history[] = array( 'time' => 445856424, 'event' => 'status-trash', 'user' => 'sam' ); 916 $history[] = array( 'time' => 445856425, 'event' => 'status-spam', 'user' => 'sam' ); 917 $history[] = array( 'time' => 445856426, 'event' => 'status-hold', 'user' => 'sam' ); 918 $history[] = array( 'time' => 445856427, 'event' => 'status-approve', 'user' => 'sam' ); 919 $history[] = array( 'time' => 445856427, 'event' => 'webhook-spam' ); 920 $history[] = array( 'time' => 445856427, 'event' => 'webhook-ham' ); 921 $history[] = array( 'time' => 445856427, 'event' => 'webhook-spam-noaction' ); 922 $history[] = array( 'time' => 445856427, 'event' => 'webhook-ham-noaction' ); 923 */ 924 925 // Validate history entries to guard against malformed data. 926 // In one case, serialized data was returned in $entry instead of an array. 927 $history = array_filter( 928 $history, 929 function ( $entry ) { 930 return is_array( $entry ) && isset( $entry['time'] ) && is_numeric( $entry['time'] ); 931 } 932 ); 933 934 usort( $history, 'Akismet::_cmp_time' ); 935 936 return $history; 937 } 938 939 /** 940 * Log an event for a given comment, storing it in comment_meta. 941 * 942 * @param int $comment_id The ID of the relevant comment. 943 * @param string $message The string description of the event. No longer used. 944 * @param string $event The event code. 945 * @param array $meta Metadata about the history entry. e.g., the user that reported or changed the status of a given comment. 946 */ 947 public static function update_comment_history( $comment_id, $message, $event = null, $meta = null ) { 948 global $current_user; 949 950 $user = ''; 951 952 $event = array( 953 'time' => self::_get_microtime(), 954 'event' => $event, 955 ); 956 957 if ( is_object( $current_user ) && isset( $current_user->user_login ) ) { 958 $event['user'] = $current_user->user_login; 959 } 960 961 if ( ! empty( $meta ) ) { 962 $event['meta'] = $meta; 963 } 964 965 // $unique = false so as to allow multiple values per comment 966 $r = add_comment_meta( $comment_id, 'akismet_history', $event, false ); 967 } 968 969 public static function check_db_comment( $id, $recheck_reason = 'recheck_queue' ) { 970 global $wpdb; 971 972 if ( ! self::get_api_key() ) { 973 return new WP_Error( 'akismet-not-configured', __( 'Akismet is not configured. Please enter an API key.', 'akismet' ) ); 974 } 975 976 $c = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->comments} WHERE comment_ID = %d", $id ), ARRAY_A ); 977 978 if ( ! $c ) { 979 return new WP_Error( 'invalid-comment-id', __( 'Comment not found.', 'akismet' ) ); 980 } 981 982 $c['user_ip'] = $c['comment_author_IP']; 983 $c['user_agent'] = $c['comment_agent']; 984 $c['referrer'] = ''; 985 $c['blog'] = get_option( 'home' ); 986 $c['blog_lang'] = get_locale(); 987 $c['blog_charset'] = get_option( 'blog_charset' ); 988 $c['permalink'] = get_permalink( $c['comment_post_ID'] ); 989 $c['recheck_reason'] = $recheck_reason; 990 991 $c['user_role'] = ''; 992 if ( ! empty( $c['user_ID'] ) ) { 993 $c['user_role'] = self::get_user_roles( $c['user_ID'] ); 994 } 995 996 if ( self::is_test_mode() ) { 997 $c['is_test'] = 'true'; 998 } 999 1000 $c = apply_filters( 'akismet_request_args', $c, 'comment-check' ); 1001 1002 $response = self::http_post( self::build_query( $c ), 'comment-check' ); 1003 1004 if ( ! empty( $response[1] ) ) { 1005 return $response[1]; 1006 } 1007 1008 return false; 1009 } 1010 1011 public static function recheck_comment( $id, $recheck_reason = 'recheck_queue' ) { 1012 add_comment_meta( $id, 'akismet_rechecking', true ); 1013 1014 $api_response = self::check_db_comment( $id, $recheck_reason ); 1015 1016 if ( is_wp_error( $api_response ) ) { 1017 // Invalid comment ID. 1018 } elseif ( 'true' === $api_response ) { 1019 wp_set_comment_status( $id, 'spam' ); 1020 update_comment_meta( $id, 'akismet_result', 'true' ); 1021 delete_comment_meta( $id, 'akismet_error' ); 1022 delete_comment_meta( $id, 'akismet_delay_moderation_email' ); 1023 delete_comment_meta( $id, 'akismet_delayed_moderation_email' ); 1024 delete_comment_meta( $id, 'akismet_schedule_approval_fallback' ); 1025 delete_comment_meta( $id, 'akismet_schedule_email_fallback' ); 1026 self::update_comment_history( $id, '', 'recheck-spam' ); 1027 } elseif ( 'false' === $api_response ) { 1028 update_comment_meta( $id, 'akismet_result', 'false' ); 1029 delete_comment_meta( $id, 'akismet_error' ); 1030 delete_comment_meta( $id, 'akismet_delay_moderation_email' ); 1031 delete_comment_meta( $id, 'akismet_delayed_moderation_email' ); 1032 delete_comment_meta( $id, 'akismet_schedule_approval_fallback' ); 1033 delete_comment_meta( $id, 'akismet_schedule_email_fallback' ); 1034 self::update_comment_history( $id, '', 'recheck-ham' ); 1035 } else { 1036 // abnormal result: error 1037 update_comment_meta( $id, 'akismet_result', 'error' ); 1038 self::update_comment_history( 1039 $id, 1040 '', 1041 'recheck-error', 1042 array( 'response' => substr( $api_response, 0, 50 ) ) 1043 ); 1044 } 1045 1046 delete_comment_meta( $id, 'akismet_rechecking' ); 1047 1048 return $api_response; 1049 } 1050 1051 public static function transition_comment_status( $new_status, $old_status, $comment ) { 1052 1053 if ( $new_status == $old_status ) { 1054 return; 1055 } 1056 1057 if ( 'spam' === $new_status || 'spam' === $old_status ) { 1058 // Clear the cache of the "X comments in your spam queue" count on the dashboard. 1059 wp_cache_delete( 'akismet_spam_count', 'widget' ); 1060 } 1061 1062 // we don't need to record a history item for deleted comments 1063 if ( $new_status == 'delete' ) { 1064 return; 1065 } 1066 1067 if ( ! current_user_can( 'edit_post', $comment->comment_post_ID ) && ! current_user_can( 'moderate_comments' ) ) { 1068 return; 1069 } 1070 1071 if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING == true ) { 1072 return; 1073 } 1074 1075 // if this is present, it means the status has been changed by a re-check, not an explicit user action 1076 if ( get_comment_meta( $comment->comment_ID, 'akismet_rechecking' ) ) { 1077 return; 1078 } 1079 1080 if ( function_exists( 'getallheaders' ) ) { 1081 $request_headers = getallheaders(); 1082 1083 foreach ( $request_headers as $header => $value ) { 1084 if ( strtolower( $header ) == 'x-akismet-webhook' ) { 1085 // This change is due to a webhook request. 1086 return; 1087 } 1088 } 1089 } 1090 1091 // Assumption alert: 1092 // We want to submit comments to Akismet only when a moderator explicitly spams or approves it - not if the status 1093 // is changed automatically by another plugin. Unfortunately WordPress doesn't provide an unambiguous way to 1094 // determine why the transition_comment_status action was triggered. And there are several different ways by which 1095 // to spam and unspam comments: bulk actions, ajax, links in moderation emails, the dashboard, and perhaps others. 1096 // We'll assume that this is an explicit user action if certain POST/GET variables exist. 1097 if ( 1098 // status=spam: Marking as spam via the REST API or... 1099 // status=unspam: I'm not sure. Maybe this used to be used instead of status=approved? Or the UI for removing from spam but not approving has been since removed?... 1100 // status=approved: Unspamming via the REST API (Calypso) or... 1101 ( isset( $_POST['status'] ) && in_array( $_POST['status'], array( 'spam', 'unspam', 'approved' ) ) ) 1102 // spam=1: Clicking "Spam" underneath a comment in wp-admin and allowing the AJAX request to happen. 1103 || ( isset( $_POST['spam'] ) && (int) $_POST['spam'] == 1 ) 1104 // unspam=1: Clicking "Not Spam" underneath a comment in wp-admin and allowing the AJAX request to happen. Or, clicking "Undo" after marking something as spam. 1105 || ( isset( $_POST['unspam'] ) && (int) $_POST['unspam'] == 1 ) 1106 // comment_status=spam/unspam: It's unclear where this is happening. 1107 || ( isset( $_POST['comment_status'] ) && in_array( $_POST['comment_status'], array( 'spam', 'unspam' ) ) ) 1108 // action=spam: Choosing "Mark as Spam" from the Bulk Actions dropdown in wp-admin (or the "Spam it" link in notification emails). 1109 // action=unspam: Choosing "Not Spam" from the Bulk Actions dropdown in wp-admin. 1110 // action=spamcomment: Following the "Spam" link below a comment in wp-admin (not allowing AJAX request to happen). 1111 // action=unspamcomment: Following the "Not Spam" link below a comment in wp-admin (not allowing AJAX request to happen). 1112 || ( isset( $_GET['action'] ) && in_array( $_GET['action'], array( 'spam', 'unspam', 'spamcomment', 'unspamcomment' ) ) ) 1113 // action=editedcomment: Editing a comment via wp-admin (and possibly changing its status). 1114 || ( isset( $_POST['action'] ) && in_array( $_POST['action'], array( 'editedcomment' ) ) ) 1115 // for=jetpack: Moderation via the WordPress app, Calypso, anything powered by the Jetpack connection. 1116 || ( isset( $_GET['for'] ) && ( 'jetpack' == $_GET['for'] ) && ( ! defined( 'IS_WPCOM' ) || ! IS_WPCOM ) ) 1117 // Certain WordPress.com API requests 1118 || ( defined( 'REST_API_REQUEST' ) && REST_API_REQUEST ) 1119 // WordPress.org REST API requests 1120 || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) 1121 ) { 1122 if ( $new_status == 'spam' && ( $old_status == 'approved' || $old_status == 'unapproved' || ! $old_status ) ) { 1123 return self::submit_spam_comment( $comment->comment_ID ); 1124 } elseif ( $old_status == 'spam' && ( $new_status == 'approved' || $new_status == 'unapproved' ) ) { 1125 return self::submit_nonspam_comment( $comment->comment_ID ); 1126 } 1127 } 1128 1129 self::update_comment_history( $comment->comment_ID, '', 'status-' . $new_status ); 1130 } 1131 1132 public static function submit_spam_comment( $comment_id ) { 1133 global $wpdb, $current_user, $current_site; 1134 1135 $comment_id = (int) $comment_id; 1136 1137 $comment = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->comments} WHERE comment_ID = %d", $comment_id ), ARRAY_A ); 1138 1139 if ( ! $comment ) { 1140 // it was deleted 1141 return; 1142 } 1143 1144 if ( 'spam' != $comment['comment_approved'] ) { 1145 return; 1146 } 1147 1148 self::update_comment_history( $comment_id, '', 'report-spam' ); 1149 1150 // If the user hasn't configured Akismet, there's nothing else to do at this point. 1151 if ( ! self::get_api_key() ) { 1152 return; 1153 } 1154 1155 // use the original version stored in comment_meta if available 1156 $as_submitted = self::sanitize_comment_as_submitted( get_comment_meta( $comment_id, 'akismet_as_submitted', true ) ); 1157 1158 if ( $as_submitted && is_array( $as_submitted ) && isset( $as_submitted['comment_content'] ) ) { 1159 $comment = array_merge( $comment, $as_submitted ); 1160 } 1161 1162 $comment['blog'] = get_option( 'home' ); 1163 $comment['blog_lang'] = get_locale(); 1164 $comment['blog_charset'] = get_option( 'blog_charset' ); 1165 $comment['permalink'] = get_permalink( $comment['comment_post_ID'] ); 1166 1167 if ( is_object( $current_user ) ) { 1168 $comment['reporter'] = $current_user->user_login; 1169 } 1170 1171 if ( is_object( $current_site ) ) { 1172 $comment['site_domain'] = $current_site->domain; 1173 } 1174 1175 $comment['user_role'] = ''; 1176 if ( ! empty( $comment['user_ID'] ) ) { 1177 $comment['user_role'] = self::get_user_roles( $comment['user_ID'] ); 1178 } 1179 1180 if ( self::is_test_mode() ) { 1181 $comment['is_test'] = 'true'; 1182 } 1183 1184 $post = get_post( $comment['comment_post_ID'] ); 1185 1186 if ( ! is_null( $post ) ) { 1187 $comment['comment_post_modified_gmt'] = $post->post_modified_gmt; 1188 } 1189 1190 $comment['comment_check_response'] = self::last_comment_check_response( $comment_id ); 1191 1192 $comment = apply_filters( 'akismet_request_args', $comment, 'submit-spam' ); 1193 1194 $response = self::http_post( self::build_query( $comment ), 'submit-spam' ); 1195 1196 update_comment_meta( $comment_id, 'akismet_user_result', 'true' ); 1197 1198 if ( $comment['reporter'] ) { 1199 update_comment_meta( $comment_id, 'akismet_user', $comment['reporter'] ); 1200 } 1201 1202 do_action( 'akismet_submit_spam_comment', $comment_id, $response[1] ); 1203 } 1204 1205 public static function submit_nonspam_comment( $comment_id ) { 1206 global $wpdb, $current_user, $current_site; 1207 1208 $comment_id = (int) $comment_id; 1209 1210 $comment = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->comments} WHERE comment_ID = %d", $comment_id ), ARRAY_A ); 1211 1212 if ( ! $comment ) { 1213 // it was deleted 1214 return; 1215 } 1216 1217 self::update_comment_history( $comment_id, '', 'report-ham' ); 1218 1219 // If the user hasn't configured Akismet, there's nothing else to do at this point. 1220 if ( ! self::get_api_key() ) { 1221 return; 1222 } 1223 1224 // use the original version stored in comment_meta if available 1225 $as_submitted = self::sanitize_comment_as_submitted( get_comment_meta( $comment_id, 'akismet_as_submitted', true ) ); 1226 1227 if ( $as_submitted && is_array( $as_submitted ) && isset( $as_submitted['comment_content'] ) ) { 1228 $comment = array_merge( $comment, $as_submitted ); 1229 } 1230 1231 $comment['blog'] = get_option( 'home' ); 1232 $comment['blog_lang'] = get_locale(); 1233 $comment['blog_charset'] = get_option( 'blog_charset' ); 1234 $comment['permalink'] = get_permalink( $comment['comment_post_ID'] ); 1235 $comment['user_role'] = ''; 1236 1237 if ( is_object( $current_user ) ) { 1238 $comment['reporter'] = $current_user->user_login; 1239 } 1240 1241 if ( is_object( $current_site ) ) { 1242 $comment['site_domain'] = $current_site->domain; 1243 } 1244 1245 if ( ! empty( $comment['user_ID'] ) ) { 1246 $comment['user_role'] = self::get_user_roles( $comment['user_ID'] ); 1247 } 1248 1249 if ( self::is_test_mode() ) { 1250 $comment['is_test'] = 'true'; 1251 } 1252 1253 $post = get_post( $comment['comment_post_ID'] ); 1254 1255 if ( ! is_null( $post ) ) { 1256 $comment['comment_post_modified_gmt'] = $post->post_modified_gmt; 1257 } 1258 1259 $comment['comment_check_response'] = self::last_comment_check_response( $comment_id ); 1260 1261 $comment = apply_filters( 'akismet_request_args', $comment, 'submit-ham' ); 1262 1263 $response = self::http_post( self::build_query( $comment ), 'submit-ham' ); 1264 1265 update_comment_meta( $comment_id, 'akismet_user_result', 'false' ); 1266 1267 if ( $comment['reporter'] ) { 1268 update_comment_meta( $comment_id, 'akismet_user', $comment['reporter'] ); 1269 } 1270 1271 do_action( 'akismet_submit_nonspam_comment', $comment_id, $response[1] ); 1272 } 1273 1274 public static function cron_recheck() { 1275 global $wpdb; 1276 1277 $api_key = self::get_api_key(); 1278 1279 $status = self::verify_key( $api_key ); 1280 if ( get_option( 'akismet_alert_code' ) || $status == 'invalid' ) { 1281 // since there is currently a problem with the key, reschedule a check for 6 hours hence 1282 wp_schedule_single_event( time() + 21600, 'akismet_schedule_cron_recheck' ); 1283 do_action( 'akismet_scheduled_recheck', 'key-problem-' . get_option( 'akismet_alert_code' ) . '-' . $status ); 1284 return false; 1285 } 1286 1287 delete_option( 'akismet_available_servers' ); 1288 1289 $comment_errors = $wpdb->get_col( "SELECT comment_id FROM {$wpdb->commentmeta} WHERE meta_key = 'akismet_error' LIMIT 100" ); 1290 1291 foreach ( (array) $comment_errors as $comment_id ) { 1292 // if the comment no longer exists, or is too old, remove the meta entry from the queue to avoid getting stuck 1293 $comment = get_comment( $comment_id ); 1294 1295 if ( 1296 ! $comment // Comment has been deleted 1297 || strtotime( $comment->comment_date_gmt ) < strtotime( '-15 days' ) // Comment is too old. 1298 || $comment->comment_approved !== '0' // Comment is no longer in the Pending queue 1299 ) { 1300 delete_comment_meta( $comment_id, 'akismet_error' ); 1301 delete_comment_meta( $comment_id, 'akismet_delay_moderation_email' ); 1302 delete_comment_meta( $comment_id, 'akismet_delayed_moderation_email' ); 1303 delete_comment_meta( $comment_id, 'akismet_schedule_approval_fallback' ); 1304 delete_comment_meta( $comment_id, 'akismet_schedule_email_fallback' ); 1305 continue; 1306 } 1307 1308 add_comment_meta( $comment_id, 'akismet_rechecking', true ); 1309 $status = self::check_db_comment( $comment_id, 'retry' ); 1310 1311 $event = ''; 1312 if ( $status == 'true' ) { 1313 $event = 'cron-retry-spam'; 1314 } elseif ( $status == 'false' ) { 1315 $event = 'cron-retry-ham'; 1316 } 1317 1318 // If we got back a legit response then update the comment history 1319 // other wise just bail now and try again later. No point in 1320 // re-trying all the comments once we hit one failure. 1321 if ( ! empty( $event ) ) { 1322 delete_comment_meta( $comment_id, 'akismet_error' ); 1323 self::update_comment_history( $comment_id, '', $event ); 1324 update_comment_meta( $comment_id, 'akismet_result', $status ); 1325 // make sure the comment status is still pending. if it isn't, that means the user has already moved it elsewhere. 1326 $comment = get_comment( $comment_id ); 1327 if ( $comment && 'unapproved' == wp_get_comment_status( $comment_id ) ) { 1328 if ( $status == 'true' ) { 1329 wp_spam_comment( $comment_id ); 1330 } elseif ( $status == 'false' ) { 1331 // comment is good, but it's still in the pending queue. depending on the moderation settings 1332 // we may need to change it to approved. 1333 if ( check_comment( $comment->comment_author, $comment->comment_author_email, $comment->comment_author_url, $comment->comment_content, $comment->comment_author_IP, $comment->comment_agent, $comment->comment_type ) ) { 1334 wp_set_comment_status( $comment_id, 1 ); 1335 } elseif ( get_comment_meta( $comment_id, 'akismet_delayed_moderation_email', true ) ) { 1336 wp_new_comment_notify_moderator( $comment_id ); 1337 wp_new_comment_notify_postauthor( $comment_id ); 1338 } 1339 } 1340 } 1341 1342 delete_comment_meta( $comment_id, 'akismet_delay_moderation_email' ); 1343 delete_comment_meta( $comment_id, 'akismet_delayed_moderation_email' ); 1344 } else { 1345 // If this comment has been pending moderation for longer than MAX_DELAY_BEFORE_MODERATION_EMAIL, 1346 // send a moderation email now. 1347 if ( ( intval( gmdate( 'U' ) ) - strtotime( $comment->comment_date_gmt ) ) < self::MAX_DELAY_BEFORE_MODERATION_EMAIL ) { 1348 delete_comment_meta( $comment_id, 'akismet_delay_moderation_email' ); 1349 delete_comment_meta( $comment_id, 'akismet_delayed_moderation_email' ); 1350 1351 wp_new_comment_notify_moderator( $comment_id ); 1352 wp_new_comment_notify_postauthor( $comment_id ); 1353 } 1354 1355 delete_comment_meta( $comment_id, 'akismet_rechecking' ); 1356 wp_schedule_single_event( time() + 1200, 'akismet_schedule_cron_recheck' ); 1357 do_action( 'akismet_scheduled_recheck', 'check-db-comment-' . $status ); 1358 1359 return; 1360 } 1361 1362 delete_comment_meta( $comment_id, 'akismet_rechecking' ); 1363 } 1364 1365 $remaining = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->commentmeta} WHERE meta_key = 'akismet_error'" ); 1366 1367 if ( $remaining && ! wp_next_scheduled( 'akismet_schedule_cron_recheck' ) ) { 1368 wp_schedule_single_event( time() + 1200, 'akismet_schedule_cron_recheck' ); 1369 do_action( 'akismet_scheduled_recheck', 'remaining' ); 1370 } 1371 } 1372 1373 public static function fix_scheduled_recheck() { 1374 $future_check = wp_next_scheduled( 'akismet_schedule_cron_recheck' ); 1375 if ( ! $future_check ) { 1376 return; 1377 } 1378 1379 if ( get_option( 'akismet_alert_code' ) > 0 ) { 1380 return; 1381 } 1382 1383 $check_range = time() + 1200; 1384 if ( $future_check > $check_range ) { 1385 wp_clear_scheduled_hook( 'akismet_schedule_cron_recheck' ); 1386 wp_schedule_single_event( time() + 300, 'akismet_schedule_cron_recheck' ); 1387 do_action( 'akismet_scheduled_recheck', 'fix-scheduled-recheck' ); 1388 } 1389 } 1390 1391 public static function add_comment_nonce( $post_id ) { 1392 /** 1393 * To disable the Akismet comment nonce, add a filter for the 'akismet_comment_nonce' tag 1394 * and return any string value that is not 'true' or '' (empty string). 1395 * 1396 * Don't return boolean false, because that implies that the 'akismet_comment_nonce' option 1397 * has not been set and that Akismet should just choose the default behavior for that 1398 * situation. 1399 */ 1400 1401 if ( ! self::get_api_key() ) { 1402 return; 1403 } 1404 1405 $akismet_comment_nonce_option = apply_filters( 'akismet_comment_nonce', get_option( 'akismet_comment_nonce' ) ); 1406 1407 if ( $akismet_comment_nonce_option == 'true' || $akismet_comment_nonce_option == '' ) { 1408 echo '<p style="display: none;">'; 1409 wp_nonce_field( 'akismet_comment_nonce_' . $post_id, 'akismet_comment_nonce', false ); 1410 echo '</p>'; 1411 } 1412 } 1413 1414 public static function is_test_mode() { 1415 return defined( 'AKISMET_TEST_MODE' ) && AKISMET_TEST_MODE; 1416 } 1417 1418 public static function allow_discard() { 1419 if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { 1420 return false; 1421 } 1422 if ( is_user_logged_in() ) { 1423 return false; 1424 } 1425 1426 return ( get_option( 'akismet_strictness' ) === '1' ); 1427 } 1428 1429 public static function get_ip_address() { 1430 return isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : null; 1431 } 1432 1433 /** 1434 * Using the unique values that we assign, do we consider these two comments 1435 * to be the same instance of a comment? 1436 * 1437 * The only fields that matter in $comment1 and $comment2 are akismet_guid and akismet_skipped_microtime. 1438 * We set both of these during the comment-check call, and if the comment has been saved to the DB, 1439 * we save them as comment meta and add them back into the comment array before comparing the comments. 1440 * 1441 * @param mixed $comment1 A comment object or array. 1442 * @param mixed $comment2 A comment object or array. 1443 * @return bool Whether the two comments should be treated as the same comment. 1444 */ 1445 private static function comments_match( $comment1, $comment2 ) { 1446 $comment1 = (array) $comment1; 1447 $comment2 = (array) $comment2; 1448 1449 if ( ! empty( $comment1['akismet_guid'] ) && ! empty( $comment2['akismet_guid'] ) ) { 1450 // If the comment got sent to the API and got a response, it will have a GUID. 1451 1452 return ( $comment1['akismet_guid'] == $comment2['akismet_guid'] ); 1453 } else if ( ! empty( $comment1['akismet_skipped_microtime'] ) && ! empty( $comment2['akismet_skipped_microtime'] ) ) { 1454 // It won't have a GUID if it didn't get sent to the API because it matched the disallowed list, 1455 // but it should have a microtimestamp to use here for matching against the comment DB entry it matches. 1456 return ( strval( $comment1['akismet_skipped_microtime'] ) == strval( $comment2['akismet_skipped_microtime'] ) ); 1457 } 1458 1459 return false; 1460 } 1461 1462 /** 1463 * Does the supplied comment match the details of the one most recently stored in self::$last_comment? 1464 * 1465 * @param array $comment 1466 * @return bool Whether the comment supplied as an argument is a match for the one we have stored in $last_comment. 1467 */ 1468 public static function matches_last_comment( $comment ) { 1469 if ( ! self::$last_comment ) { 1470 return false; 1471 } 1472 1473 return self::comments_match( $comment, self::$last_comment ); 1474 } 1475 1476 /** 1477 * Because of the order of operations, we don't always know the comment ID of the comment that we're checking, 1478 * so we have to be able to match the comment we cached locally with the comment from the DB. 1479 * 1480 * @param int $comment_id 1481 * @return bool Whether the comment represented by $comment_id is a match for the one we have stored in $last_comment. 1482 */ 1483 public static function matches_last_comment_by_id( $comment_id ) { 1484 return self::matches_last_comment( self::get_fields_for_comment_matching( $comment_id ) ); 1485 } 1486 1487 /** 1488 * Given a comment ID, retrieve the values that we use for matching comments together. 1489 * 1490 * @param int $comment_id 1491 * @return array An array containing akismet_guid and akismet_skipped_microtime. Either or both may be falsy, but we hope that at least one is a string. 1492 */ 1493 public static function get_fields_for_comment_matching( $comment_id ) { 1494 return array( 1495 'akismet_guid' => get_comment_meta( $comment_id, 'akismet_guid', true ), 1496 'akismet_skipped_microtime' => get_comment_meta( $comment_id, 'akismet_skipped_microtime', true ), 1497 ); 1498 } 1499 1500 private static function get_user_agent() { 1501 return isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : null; 1502 } 1503 1504 private static function get_referer() { 1505 return isset( $_SERVER['HTTP_REFERER'] ) ? $_SERVER['HTTP_REFERER'] : null; 1506 } 1507 1508 // return a comma-separated list of role names for the given user 1509 public static function get_user_roles( $user_id ) { 1510 $comment_user = null; 1511 $roles = false; 1512 1513 if ( ! class_exists( 'WP_User' ) ) { 1514 return false; 1515 } 1516 1517 if ( $user_id > 0 ) { 1518 $comment_user = new WP_User( $user_id ); 1519 if ( isset( $comment_user->roles ) ) { 1520 $roles = implode( ',', $comment_user->roles ); 1521 } 1522 } 1523 1524 if ( is_multisite() && is_super_admin( $user_id ) ) { 1525 if ( empty( $roles ) ) { 1526 $roles = 'super_admin'; 1527 } else { 1528 $comment_user->roles[] = 'super_admin'; 1529 $roles = implode( ',', $comment_user->roles ); 1530 } 1531 } 1532 1533 return $roles; 1534 } 1535 1536 // filter handler used to return a spam result to pre_comment_approved 1537 public static function last_comment_status( $approved, $comment ) { 1538 if ( is_null( self::$last_comment_result ) ) { 1539 // We didn't have reason to store the result of the last check. 1540 return $approved; 1541 } 1542 1543 // Only do this if it's the correct comment. 1544 if ( ! self::matches_last_comment( $comment ) ) { 1545 self::log( "comment_is_spam mismatched comment, returning unaltered $approved" ); 1546 return $approved; 1547 } 1548 1549 if ( 'trash' === $approved ) { 1550 // If the last comment we checked has had its approval set to 'trash', 1551 // then it failed the comment blacklist check. Let that blacklist override 1552 // the spam check, since users have the (valid) expectation that when 1553 // they fill out their blacklists, comments that match it will always 1554 // end up in the trash. 1555 return $approved; 1556 } 1557 1558 // bump the counter here instead of when the filter is added to reduce the possibility of overcounting 1559 if ( $incr = apply_filters( 'akismet_spam_count_incr', 1 ) ) { 1560 update_option( 'akismet_spam_count', get_option( 'akismet_spam_count' ) + $incr ); 1561 } 1562 1563 return self::$last_comment_result; 1564 } 1565 1566 /** 1567 * If Akismet is temporarily unreachable, we don't want to "spam" the blogger or post author 1568 * with emails for comments that will be automatically cleared or spammed on the next retry. 1569 * 1570 * @param bool $maybe_notify Whether the notification email will be sent. 1571 * @param int $comment_id The ID of the relevant comment. 1572 * @return bool Whether the notification email should still be sent. 1573 */ 1574 public static function disable_emails_if_unreachable( $maybe_notify, $comment_id ) { 1575 if ( $maybe_notify ) { 1576 if ( get_comment_meta( $comment_id, 'akismet_delay_moderation_email', true ) ) { 1577 self::log( 'Disabling notification email for comment #' . $comment_id ); 1578 1579 update_comment_meta( $comment_id, 'akismet_delayed_moderation_email', true ); 1580 delete_comment_meta( $comment_id, 'akismet_delay_moderation_email' ); 1581 1582 // If we want to prevent the email from sending another time, we'll have to reset 1583 // the akismet_delay_moderation_email commentmeta. 1584 1585 return false; 1586 } 1587 } 1588 1589 return $maybe_notify; 1590 } 1591 1592 public static function _cmp_time( $a, $b ) { 1593 return $a['time'] > $b['time'] ? -1 : 1; 1594 } 1595 1596 public static function _get_microtime() { 1597 $mtime = explode( ' ', microtime() ); 1598 return $mtime[1] + $mtime[0]; 1599 } 1600 1601 /** 1602 * Make a POST request to the Akismet API. 1603 * 1604 * @param string $request The body of the request. 1605 * @param string $path The path for the request. 1606 * @param string $ip The specific IP address to hit. 1607 * @return array A two-member array consisting of the headers and the response body, both empty in the case of a failure. 1608 */ 1609 public static function http_post( $request, $path, $ip = null ) { 1610 1611 $akismet_ua = sprintf( 'WordPress/%s | Akismet/%s', $GLOBALS['wp_version'], constant( 'AKISMET_VERSION' ) ); 1612 $akismet_ua = apply_filters( 'akismet_ua', $akismet_ua ); 1613 1614 $host = self::API_HOST; 1615 $api_key = self::get_api_key(); 1616 1617 if ( $api_key ) { 1618 $request = add_query_arg( 'api_key', $api_key, $request ); 1619 } 1620 1621 $http_host = $host; 1622 // use a specific IP if provided 1623 // needed by Akismet_Admin::check_server_connectivity() 1624 if ( $ip && long2ip( ip2long( $ip ) ) ) { 1625 $http_host = $ip; 1626 } 1627 1628 $http_args = array( 1629 'body' => $request, 1630 'headers' => array( 1631 'Content-Type' => 'application/x-www-form-urlencoded; charset=' . get_option( 'blog_charset' ), 1632 'Host' => $host, 1633 'User-Agent' => $akismet_ua, 1634 ), 1635 'httpversion' => '1.0', 1636 'timeout' => 15, 1637 ); 1638 1639 $akismet_url = $http_akismet_url = "http://{$http_host}/1.1/{$path}"; 1640 1641 /** 1642 * Try SSL first; if that fails, try without it and don't try it again for a while. 1643 */ 1644 1645 $ssl = $ssl_failed = false; 1646 1647 // Check if SSL requests were disabled fewer than X hours ago. 1648 $ssl_disabled = get_option( 'akismet_ssl_disabled' ); 1649 1650 if ( $ssl_disabled && $ssl_disabled < ( time() - 60 * 60 * 24 ) ) { // 24 hours 1651 $ssl_disabled = false; 1652 delete_option( 'akismet_ssl_disabled' ); 1653 } elseif ( $ssl_disabled ) { 1654 do_action( 'akismet_ssl_disabled' ); 1655 } 1656 1657 if ( ! $ssl_disabled && ( $ssl = wp_http_supports( array( 'ssl' ) ) ) ) { 1658 $akismet_url = set_url_scheme( $akismet_url, 'https' ); 1659 1660 do_action( 'akismet_https_request_pre' ); 1661 } 1662 1663 $response = wp_remote_post( $akismet_url, $http_args ); 1664 1665 self::log( compact( 'akismet_url', 'http_args', 'response' ) ); 1666 1667 if ( $ssl && is_wp_error( $response ) ) { 1668 do_action( 'akismet_https_request_failure', $response ); 1669 1670 // Intermittent connection problems may cause the first HTTPS 1671 // request to fail and subsequent HTTP requests to succeed randomly. 1672 // Retry the HTTPS request once before disabling SSL for a time. 1673 $response = wp_remote_post( $akismet_url, $http_args ); 1674 1675 self::log( compact( 'akismet_url', 'http_args', 'response' ) ); 1676 1677 if ( is_wp_error( $response ) ) { 1678 $ssl_failed = true; 1679 1680 do_action( 'akismet_https_request_failure', $response ); 1681 1682 do_action( 'akismet_http_request_pre' ); 1683 1684 // Try the request again without SSL. 1685 $response = wp_remote_post( $http_akismet_url, $http_args ); 1686 1687 self::log( compact( 'http_akismet_url', 'http_args', 'response' ) ); 1688 } 1689 } 1690 1691 if ( is_wp_error( $response ) ) { 1692 do_action( 'akismet_request_failure', $response ); 1693 1694 return array( '', '' ); 1695 } 1696 1697 if ( $ssl_failed ) { 1698 // The request failed when using SSL but succeeded without it. Disable SSL for future requests. 1699 update_option( 'akismet_ssl_disabled', time() ); 1700 1701 do_action( 'akismet_https_disabled' ); 1702 } 1703 1704 $simplified_response = array( $response['headers'], $response['body'] ); 1705 1706 $alert_code_check_paths = array( 1707 'verify-key', 1708 'comment-check', 1709 'get-stats', 1710 ); 1711 1712 if ( in_array( $path, $alert_code_check_paths ) ) { 1713 self::update_alert( $simplified_response ); 1714 } 1715 1716 return $simplified_response; 1717 } 1718 1719 // given a response from an API call like check_key_status(), update the alert code options if an alert is present. 1720 public static function update_alert( $response ) { 1721 $alert_option_prefix = 'akismet_alert_'; 1722 $alert_header_prefix = 'x-akismet-alert-'; 1723 $alert_header_names = array( 1724 'code', 1725 'msg', 1726 'api-calls', 1727 'usage-limit', 1728 'upgrade-plan', 1729 'upgrade-url', 1730 'upgrade-type', 1731 'upgrade-via-support', 1732 'recommended-plan-name', 1733 ); 1734 1735 foreach ( $alert_header_names as $alert_header_name ) { 1736 $value = null; 1737 if ( isset( $response[0][ $alert_header_prefix . $alert_header_name ] ) ) { 1738 $value = $response[0][ $alert_header_prefix . $alert_header_name ]; 1739 } 1740 1741 $option_name = $alert_option_prefix . str_replace( '-', '_', $alert_header_name ); 1742 if ( $value != get_option( $option_name ) ) { 1743 if ( ! $value ) { 1744 delete_option( $option_name ); 1745 } else { 1746 update_option( $option_name, $value ); 1747 } 1748 } 1749 } 1750 } 1751 1752 /** 1753 * Mark akismet-frontend.js as deferred. Because nothing depends on it, it can run at any time 1754 * after it's loaded, and the browser won't have to wait for it to load to continue 1755 * parsing the rest of the page. 1756 */ 1757 public static function set_form_js_async( $tag, $handle, $src ) { 1758 if ( 'akismet-frontend' !== $handle ) { 1759 return $tag; 1760 } 1761 1762 return preg_replace( '/^<script /i', '<script defer ', $tag ); 1763 } 1764 1765 public static function get_akismet_form_fields() { 1766 $fields = ''; 1767 1768 $prefix = 'ak_'; 1769 1770 // Contact Form 7 uses _wpcf7 as a prefix to know which fields to exclude from comment_content. 1771 if ( 'wpcf7_form_elements' === current_filter() ) { 1772 $prefix = '_wpcf7_ak_'; 1773 } 1774 1775 $fields .= '<p style="display: none !important;" class="akismet-fields-container" data-prefix="' . esc_attr( $prefix ) . '">'; 1776 $fields .= '<label>Δ<textarea name="' . $prefix . 'hp_textarea" cols="45" rows="8" maxlength="100"></textarea></label>'; 1777 1778 if ( ! function_exists( 'amp_is_request' ) || ! amp_is_request() ) { 1779 // Keep track of how many ak_js fields are in this page so that we don't re-use 1780 // the same ID. 1781 static $field_count = 0; 1782 1783 ++$field_count; 1784 1785 $fields .= '<input type="hidden" id="ak_js_' . $field_count . '" name="' . $prefix . 'js" value="' . mt_rand( 0, 250 ) . '"/>'; 1786 $fields .= '<script>document.getElementById( "ak_js_' . $field_count . '" ).setAttribute( "value", ( new Date() ).getTime() );</script>'; 1787 } 1788 1789 $fields .= '</p>'; 1790 1791 return $fields; 1792 } 1793 1794 public static function output_custom_form_fields( $post_id ) { 1795 if ( 'fluentform/form_element_start' === current_filter() && did_action( 'fluentform_form_element_start' ) ) { 1796 // Already did this via the legacy filter. 1797 return; 1798 } 1799 1800 // phpcs:ignore WordPress.Security.EscapeOutput 1801 echo self::get_akismet_form_fields(); 1802 } 1803 1804 public static function inject_custom_form_fields( $html ) { 1805 $html = str_replace( '</form>', self::get_akismet_form_fields() . '</form>', $html ); 1806 1807 return $html; 1808 } 1809 1810 public static function append_custom_form_fields( $html ) { 1811 $html .= self::get_akismet_form_fields(); 1812 1813 return $html; 1814 } 1815 1816 /** 1817 * Ensure that any Akismet-added form fields are included in the comment-check call. 1818 * 1819 * @param array $form 1820 * @param array $data Some plugins will supply the POST data via the filter, since they don't 1821 * read it directly from $_POST. 1822 * @return array $form 1823 */ 1824 public static function prepare_custom_form_values( $form, $data = null ) { 1825 if ( 'fluentform/akismet_fields' === current_filter() && did_filter( 'fluentform_akismet_fields' ) ) { 1826 // Already updated the form fields via the legacy filter. 1827 return $form; 1828 } 1829 1830 if ( is_null( $data ) ) { 1831 // phpcs:ignore WordPress.Security.NonceVerification.Missing 1832 $data = $_POST; 1833 } 1834 1835 $prefix = 'ak_'; 1836 1837 // Contact Form 7 uses _wpcf7 as a prefix to know which fields to exclude from comment_content. 1838 if ( 'wpcf7_akismet_parameters' === current_filter() ) { 1839 $prefix = '_wpcf7_ak_'; 1840 } 1841 1842 foreach ( $data as $key => $val ) { 1843 if ( 0 === strpos( $key, $prefix ) ) { 1844 $form[ 'POST_ak_' . substr( $key, strlen( $prefix ) ) ] = $val; 1845 } 1846 } 1847 1848 return $form; 1849 } 1850 1851 private static function bail_on_activation( $message, $deactivate = true ) { 1852 ?> 1853 <!doctype html> 1854 <html> 1855 <head> 1856 <meta charset="<?php bloginfo( 'charset' ); ?>" /> 1857 <style> 1858 * { 1859 text-align: center; 1860 margin: 0; 1861 padding: 0; 1862 font-family: "Lucida Grande",Verdana,Arial,"Bitstream Vera Sans",sans-serif; 1863 } 1864 p { 1865 margin-top: 1em; 1866 font-size: 18px; 1867 } 1868 </style> 1869 </head> 1870 <body> 1871 <p><?php echo esc_html( $message ); ?></p> 1872 </body> 1873 </html> 1874 <?php 1875 if ( $deactivate ) { 1876 $plugins = get_option( 'active_plugins' ); 1877 $akismet = plugin_basename( AKISMET__PLUGIN_DIR . 'akismet.php' ); 1878 $update = false; 1879 foreach ( $plugins as $i => $plugin ) { 1880 if ( $plugin === $akismet ) { 1881 $plugins[ $i ] = false; 1882 $update = true; 1883 } 1884 } 1885 1886 if ( $update ) { 1887 update_option( 'active_plugins', array_filter( $plugins ) ); 1888 } 1889 } 1890 exit; 1891 } 1892 1893 public static function view( $name, array $args = array() ) { 1894 $args = apply_filters( 'akismet_view_arguments', $args, $name ); 1895 1896 foreach ( $args as $key => $val ) { 1897 $$key = $val; 1898 } 1899 1900 $file = AKISMET__PLUGIN_DIR . 'views/' . basename( $name ) . '.php'; 1901 1902 if ( file_exists( $file ) ) { 1903 include $file; 1904 } 1905 } 1906 1907 /** 1908 * Attached to activate_{ plugin_basename( __FILES__ ) } by register_activation_hook() 1909 * 1910 * @static 1911 */ 1912 public static function plugin_activation() { 1913 if ( version_compare( $GLOBALS['wp_version'], AKISMET__MINIMUM_WP_VERSION, '<' ) ) { 1914 $message = '<strong>' . 1915 /* translators: 1: Current Akismet version number, 2: Minimum WordPress version number required. */ 1916 sprintf( esc_html__( 'Akismet %1$s requires WordPress %2$s or higher.', 'akismet' ), AKISMET_VERSION, AKISMET__MINIMUM_WP_VERSION ) . '</strong> ' . 1917 /* translators: 1: WordPress documentation URL, 2: Akismet download URL. */ 1918 sprintf( __( 'Please <a href="%1$s">upgrade WordPress</a> to a current version, or <a href="%2$s">downgrade to version 2.4 of the Akismet plugin</a>.', 'akismet' ), 'https://codex.wordpress.org/Upgrading_WordPress', 'https://wordpress.org/plugins/akismet' ); 1919 1920 self::bail_on_activation( $message ); 1921 } elseif ( ! empty( $_SERVER['SCRIPT_NAME'] ) && false !== strpos( $_SERVER['SCRIPT_NAME'], '/wp-admin/plugins.php' ) ) { 1922 add_option( 'Activated_Akismet', true ); 1923 } 1924 } 1925 1926 /** 1927 * Removes all connection options 1928 * 1929 * @static 1930 */ 1931 public static function plugin_deactivation() { 1932 self::deactivate_key( self::get_api_key() ); 1933 1934 // Remove any scheduled cron jobs. 1935 $akismet_cron_events = array( 1936 'akismet_schedule_cron_recheck', 1937 'akismet_scheduled_delete', 1938 ); 1939 1940 foreach ( $akismet_cron_events as $akismet_cron_event ) { 1941 $timestamp = wp_next_scheduled( $akismet_cron_event ); 1942 1943 if ( $timestamp ) { 1944 wp_unschedule_event( $timestamp, $akismet_cron_event ); 1945 } 1946 } 1947 } 1948 1949 /** 1950 * Essentially a copy of WP's build_query but one that doesn't expect pre-urlencoded values. 1951 * 1952 * @param array $args An array of key => value pairs 1953 * @return string A string ready for use as a URL query string. 1954 */ 1955 public static function build_query( $args ) { 1956 return _http_build_query( $args, '', '&' ); 1957 } 1958 1959 /** 1960 * Log debugging info to the error log. 1961 * 1962 * Enabled when WP_DEBUG_LOG is enabled (and WP_DEBUG, since according to 1963 * core, "WP_DEBUG_DISPLAY and WP_DEBUG_LOG perform no function unless 1964 * WP_DEBUG is true), but can be disabled via the akismet_debug_log filter. 1965 * 1966 * @param mixed $akismet_debug The data to log. 1967 */ 1968 public static function log( $akismet_debug ) { 1969 if ( apply_filters( 'akismet_debug_log', defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG && defined( 'AKISMET_DEBUG' ) && AKISMET_DEBUG ) ) { 1970 error_log( print_r( compact( 'akismet_debug' ), true ) ); 1971 } 1972 } 1973 1974 /** 1975 * Check pingbacks for spam before they're saved to the DB. 1976 * 1977 * @param string $method The XML-RPC method that was called. 1978 * @param array $args This and the $server arg are marked as optional since plugins might still be 1979 * calling do_action( 'xmlrpc_action', [...] ) without the arguments that were added in WP 5.7. 1980 * @param wp_xmlrpc_server $server 1981 */ 1982 public static function pre_check_pingback( $method, $args = array(), $server = null ) { 1983 if ( $method !== 'pingback.ping' ) { 1984 return; 1985 } 1986 1987 /* 1988 * $args looks like this: 1989 * 1990 * Array 1991 * ( 1992 * [0] => http://www.example.net/?p=1 // Site that created the pingback. 1993 * [1] => https://www.example.com/?p=2 // Post being pingback'd on this site. 1994 * ) 1995 */ 1996 1997 if ( ! is_null( $server ) && ! empty( $args[1] ) ) { 1998 $is_multicall = false; 1999 $multicall_count = 0; 2000 2001 if ( 'system.multicall' === $server->message->methodName ) { 2002 $is_multicall = true; 2003 $multicall_count = is_countable( $server->message->params ) ? count( $server->message->params ) : 0; 2004 } 2005 2006 $post_id = url_to_postid( $args[1] ); 2007 2008 // If pingbacks aren't open on this post, we'll still check whether this request is part of a potential DDOS, 2009 // but indicate to the server that pingbacks are indeed closed so we don't include this request in the user's stats, 2010 // since the user has already done their part by disabling pingbacks. 2011 $pingbacks_closed = false; 2012 2013 $post = get_post( $post_id ); 2014 2015 if ( ! $post || ! pings_open( $post ) ) { 2016 $pingbacks_closed = true; 2017 } 2018 2019 $comment = array( 2020 'comment_author_url' => $args[0], 2021 'comment_post_ID' => $post_id, 2022 'comment_author' => '', 2023 'comment_author_email' => '', 2024 'comment_content' => '', 2025 'comment_type' => 'pingback', 2026 'akismet_pre_check' => '1', 2027 'comment_pingback_target' => $args[1], 2028 'pingbacks_closed' => $pingbacks_closed ? '1' : '0', 2029 'is_multicall' => $is_multicall, 2030 'multicall_count' => $multicall_count, 2031 ); 2032 2033 $comment = self::auto_check_comment( $comment, 'xml-rpc' ); 2034 2035 if ( isset( $comment['akismet_result'] ) && 'true' == $comment['akismet_result'] ) { 2036 // Sad: tightly coupled with the IXR classes. Unfortunately the action provides no context and no way to return anything. 2037 $server->error( new IXR_Error( 0, 'Invalid discovery target' ) ); 2038 2039 // Also note that if this was part of a multicall, a spam result will prevent the subsequent calls from being executed. 2040 // This is probably fine, but it raises the bar for what should be acceptable as a false positive. 2041 } 2042 } 2043 } 2044 2045 /** 2046 * Ensure that we are loading expected scalar values from akismet_as_submitted commentmeta. 2047 * 2048 * @param mixed $meta_value 2049 * @return mixed 2050 */ 2051 private static function sanitize_comment_as_submitted( $meta_value ) { 2052 if ( empty( $meta_value ) ) { 2053 return $meta_value; 2054 } 2055 2056 $meta_value = (array) $meta_value; 2057 2058 foreach ( $meta_value as $key => $value ) { 2059 if ( ! is_scalar( $value ) ) { 2060 unset( $meta_value[ $key ] ); 2061 } else { 2062 // These can change, so they're not explicitly listed in comment_as_submitted_allowed_keys. 2063 if ( strpos( $key, 'POST_ak_' ) === 0 ) { 2064 continue; 2065 } 2066 2067 if ( ! isset( self::$comment_as_submitted_allowed_keys[ $key ] ) ) { 2068 unset( $meta_value[ $key ] ); 2069 } 2070 } 2071 } 2072 2073 return $meta_value; 2074 } 2075 2076 public static function predefined_api_key() { 2077 if ( defined( 'WPCOM_API_KEY' ) ) { 2078 return true; 2079 } 2080 2081 return apply_filters( 'akismet_predefined_api_key', false ); 2082 } 2083 2084 /** 2085 * Controls the display of a privacy related notice underneath the comment 2086 * form using the `akismet_comment_form_privacy_notice` option and filter 2087 * respectively. 2088 * 2089 * Default is to not display the notice, leaving the choice to site admins, 2090 * or integrators. 2091 */ 2092 public static function display_comment_form_privacy_notice() { 2093 if ( 'display' !== apply_filters( 'akismet_comment_form_privacy_notice', get_option( 'akismet_comment_form_privacy_notice', 'hide' ) ) ) { 2094 return; 2095 } 2096 2097 echo apply_filters( 2098 'akismet_comment_form_privacy_notice_markup', 2099 '<p class="akismet_comment_form_privacy_notice">' . 2100 wp_kses( 2101 sprintf( 2102 /* translators: %s: Akismet privacy URL */ 2103 __( 'This site uses Akismet to reduce spam. <a href="%s" target="_blank" rel="nofollow noopener">Learn how your comment data is processed.</a>', 'akismet' ), 2104 'https://akismet.com/privacy/' 2105 ), 2106 array( 2107 'a' => array( 2108 'href' => array(), 2109 'target' => array(), 2110 'rel' => array(), 2111 ), 2112 ) 2113 ) . 2114 '</p>' 2115 ); 2116 } 2117 2118 public static function load_form_js() { 2119 if ( 2120 ! is_admin() 2121 && ( ! function_exists( 'amp_is_request' ) || ! amp_is_request() ) 2122 && self::get_api_key() 2123 ) { 2124 wp_register_script( 'akismet-frontend', plugin_dir_url( __FILE__ ) . '_inc/akismet-frontend.js', array(), filemtime( plugin_dir_path( __FILE__ ) . '_inc/akismet-frontend.js' ), true ); 2125 wp_enqueue_script( 'akismet-frontend' ); 2126 } 2127 } 2128 2129 /** 2130 * Add the form JavaScript when we detect that a supported form shortcode is being parsed. 2131 */ 2132 public static function load_form_js_via_filter( $return_value, $tag, $attr, $m ) { 2133 if ( in_array( $tag, array( 'contact-form', 'gravityform', 'contact-form-7', 'formidable', 'fluentform' ) ) ) { 2134 self::load_form_js(); 2135 } 2136 2137 return $return_value; 2138 } 2139 2140 /** 2141 * Was the last entry in the comment history created by Akismet? 2142 * 2143 * @param int $comment_id The ID of the comment. 2144 * @return bool 2145 */ 2146 public static function last_comment_status_change_came_from_akismet( $comment_id ) { 2147 $history = self::get_comment_history( $comment_id ); 2148 2149 if ( empty( $history ) ) { 2150 return false; 2151 } 2152 2153 $most_recent_history_event = $history[0]; 2154 2155 if ( ! isset( $most_recent_history_event['event'] ) ) { 2156 return false; 2157 } 2158 2159 $akismet_history_events = array( 2160 'check-error', 2161 'cron-retry-ham', 2162 'cron-retry-spam', 2163 'check-ham', 2164 'check-ham-pending', 2165 'check-spam', 2166 'recheck-error', 2167 'recheck-ham', 2168 'recheck-spam', 2169 'webhook-ham', 2170 'webhook-spam', 2171 ); 2172 2173 if ( in_array( $most_recent_history_event['event'], $akismet_history_events ) ) { 2174 return true; 2175 } 2176 2177 return false; 2178 } 2179 2180 /** 2181 * Check the comment history to find out what the most recent comment-check 2182 * response said about this comment. 2183 * 2184 * This value is then included in submit-ham and submit-spam requests to allow 2185 * us to know whether the comment is actually a missed spam/ham or if it's 2186 * just being reclassified after either never being checked or being mistakenly 2187 * marked as ham/spam. 2188 * 2189 * @param int $comment_id The comment ID. 2190 * @return string 'true', 'false', or an empty string if we don't have a record 2191 * of comment-check being called. 2192 */ 2193 public static function last_comment_check_response( $comment_id ) { 2194 $history = self::get_comment_history( $comment_id ); 2195 2196 if ( $history ) { 2197 $history = array_reverse( $history ); 2198 2199 foreach ( $history as $akismet_history_entry ) { 2200 // We've always been consistent in how history entries are formatted 2201 // but comment_meta is writable by everyone, so don't assume that all 2202 // entries contain the expected parts. 2203 2204 if ( ! is_array( $akismet_history_entry ) ) { 2205 continue; 2206 } 2207 2208 if ( ! isset( $akismet_history_entry['event'] ) ) { 2209 continue; 2210 } 2211 2212 if ( in_array( 2213 $akismet_history_entry['event'], 2214 array( 2215 'recheck-spam', 2216 'check-spam', 2217 'cron-retry-spam', 2218 'webhook-spam', 2219 'webhook-spam-noaction', 2220 ), 2221 true 2222 ) ) { 2223 return 'true'; 2224 } elseif ( in_array( 2225 $akismet_history_entry['event'], 2226 array( 2227 'recheck-ham', 2228 'check-ham', 2229 'cron-retry-ham', 2230 'webhook-ham', 2231 'webhook-ham-noaction', 2232 ), 2233 true 2234 ) ) { 2235 return 'false'; 2236 } 2237 } 2238 } 2239 2240 return ''; 2241 } 2242 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated : Tue Jan 6 08:20:02 2026 | Cross-referenced by PHPXref |