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