PluginProbe
Disable Comments – Remove Comments & Stop Spam [Multi-Site Support] / 2.9.0
Disable Comments – Remove Comments & Stop Spam [Multi-Site Support] v2.9.0
2.9.0 2.8.0 2.7.1 trunk 1.1.1 1.10.0 1.10.3 1.11.0 1.5 1.5.1 1.5.2 1.6 1.7 1.7.1 1.8.0 1.9.0 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.1.2 2.2.0 2.2.1 2.2.2 All 50 releases
disable-comments / disable-comments.php

disable-comments.php in Disable Comments – Remove Comments & Stop Spam [Multi-Site Support] 2.9.0, at disable-comments.php

6,077 lines 219.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Plugin Name: Disable Comments
5 * Plugin URI: https://wordpress.org/plugins/disable-comments/
6 * Description: Allows administrators to globally disable comments on their site. Comments can be disabled according to post type. You could bulk delete comments using Tools.
7 * Version: 2.9.0
8 * Author: WPDeveloper
9 * Author URI: https://wpdeveloper.com
10 * License: GPL-3.0+
11 * License URI: https://www.gnu.org/licenses/gpl-3.0.html
12 * Text Domain: disable-comments
13 * Domain Path: /languages/
14 *
15 * @package Disable_Comments
16 */
17
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 class Disable_Comments {
23 const DB_VERSION = 8;
24 const BLOCKED_STATS_OPTION = 'disable_comments_blocked_stats';
25 /** Option recording when counting started. */
26 const BLOCKED_SINCE_OPTION = 'disable_comments_blocked_since';
27 /**
28 * Per-user meta recording that the review prompt was dismissed.
29 *
30 * User meta rather than an option: dismissal belongs to the person who
31 * dismissed it, not to the site. It also survives plugin updates, which
32 * a version-stamped option would not.
33 */
34 const REVIEW_DISMISSED_META = 'disable_comments_review_dismissed';
35 /** Option holding the moment a bulk delete succeeded. */
36 const REVIEW_TRIGGER_OPTION = 'disable_comments_review_trigger';
37 /**
38 * Terms the conditional-rules picker asks for at a time.
39 *
40 * A page size, not a limit: the picker searches and pages, so a taxonomy
41 * larger than this is still reachable in full. Sized to fill a dropdown
42 * without making the first keystroke wait on a query that reads thousands
43 * of rows.
44 */
45 const TERM_PAGE_SIZE = 50;
46 /**
47 * How many matching comments the delete preview shows.
48 *
49 * Enough to recognise what the delete is about to take, not so many that
50 * the panel becomes the comments screen. The full set is what the CSV
51 * backup beside the button is for.
52 */
53 const DELETE_PREVIEW_SAMPLE_SIZE = 10;
54 /** Transient holding the current scan's one-time token. */
55 const SCAN_TOKEN_TRANSIENT = 'disable_comments_scan_token';
56 /** Query arg carrying that token on the scanned request. */
57 const SCAN_QUERY_ARG = 'dc_scan';
58 /**
59 * Format version of the settings export payload.
60 *
61 * Bumped when the shape changes, so a future release can migrate an old
62 * file rather than silently misapplying it.
63 */
64 const EXPORT_SCHEMA_VERSION = 1;
65 private static $instance = null;
66 private $options;
67 public $networkactive;
68 public $tracker;
69 public $is_CLI;
70 public $sitewide_settings;
71 public $setup_notice_flag;
72 private $modified_types = array();
73 /**
74 * Blocked attempts seen in this request, flushed once on shutdown.
75 *
76 * Incrementing an option per blocked request would put a write on the
77 * exact path a spam flood hammers, so counting stays in memory until the
78 * request is over.
79 *
80 * @var array
81 */
82 private $blocked_pending = array();
83 /**
84 * Comments removed by the last delete run in this request.
85 *
86 * apply_delete_comments() already works this out to decide whether the
87 * run earned a review prompt; it is kept so the AJAX response can report
88 * it, because the log string it returns is prose and cannot be counted on.
89 *
90 * @var int
91 */
92 private $last_deleted_count = 0;
93 /** Findings accumulated during a scanned front-end request. */
94 private $scan_report = array();
95
96 public static function get_instance() {
97 if (is_null(self::$instance)) {
98 self::$instance = new self;
99 }
100 return self::$instance;
101 }
102
103 function __construct() {
104 define('DC_VERSION', '2.9.0');
105 define('DC_PLUGIN_SLUG', 'disable_comments_settings');
106 define('DC_PLUGIN_ROOT_PATH', dirname(__FILE__));
107 define('DC_PLUGIN_VIEWS_PATH', DC_PLUGIN_ROOT_PATH . '/views/');
108 define('DC_PLUGIN_ROOT_URI', plugins_url("/", __FILE__));
109 define('DC_ASSETS_URI', DC_PLUGIN_ROOT_URI . 'assets/');
110
111 // save settings
112 add_action('wp_ajax_disable_comments_save_settings', array($this, 'disable_comments_settings'));
113 add_action('wp_ajax_disable_comments_delete_comments', array($this, 'delete_comments_settings'));
114 add_action('wp_ajax_disable_comments_preview_delete', array($this, 'preview_delete_comments'));
115 add_action('wp_ajax_disable_comments_export_comments', array($this, 'export_comments_download'));
116 add_action('wp_ajax_get_sub_sites', array($this, 'get_sub_sites'));
117 add_action('wp_ajax_disable_comments_get_terms', array($this, 'get_taxonomy_terms'));
118 add_action('wp_ajax_disable_comments_reset_blocked_stats', array($this, 'reset_blocked_stats_ajax'));
119 add_action('wp_ajax_disable_comments_dismiss_review', array($this, 'dismiss_review_prompt'));
120 add_action('wp_ajax_disable_comments_scan_theme', array($this, 'scan_theme_conflict'));
121 add_action('wp_ajax_disable_comments_export_settings', array($this, 'export_settings_download'));
122 add_action('wp_ajax_disable_comments_import_settings', array($this, 'import_settings_ajax'));
123
124 // Including cli.php
125 if (defined('WP_CLI') && WP_CLI) {
126 add_action('init', array($this, 'enable_cli'), 9999);
127 }
128
129 // Expose plugin state to the Abilities API (WordPress 6.9+), so AI
130 // agents and MCP clients can query how comments are configured.
131 add_action('wp_abilities_api_categories_init', array($this, 'register_ability_categories'));
132 add_action('wp_abilities_api_init', array($this, 'register_abilities'));
133
134 // are we network activated?
135 $this->networkactive = (is_multisite() && array_key_exists(plugin_basename(__FILE__), (array) get_site_option('active_sitewide_plugins')));
136 $this->is_CLI = defined('WP_CLI') && WP_CLI;
137
138 $this->sitewide_settings = get_site_option('disable_comments_sitewide_settings', false);
139 // Load options.
140 // Uses is_network_admin_ajax_context() (routing hint, not capability
141 // check) because current_user_can() is unavailable during plugin
142 // construction — pluggable.php hasn't loaded yet. This only controls
143 // which options table is READ (site vs blog) — writes are always
144 // gated by capability checks in the AJAX handlers and settings_page().
145 if ($this->networkactive && ($this->is_network_admin_ajax_context() || $this->sitewide_settings !== '1')) {
146 $this->options = get_site_option('disable_comments_options', array());
147 $this->options['disabled_sites'] = $this->get_disabled_sites();
148
149 $blog_id = get_current_blog_id();
150 if (
151 !$this->is_network_admin_ajax_context() && (
152 empty($this->options['disabled_sites']) ||
153 // if site disabled
154 empty($this->options['disabled_sites']["site_$blog_id"])
155 )
156 ) {
157 $this->options = [
158 'remove_everywhere' => false,
159 'disabled_post_types' => array(),
160 'extra_post_types' => array(),
161 'disabled_sites' => array(),
162 'remove_xmlrpc_comments' => 0,
163 'remove_rest_API_comments' => 0,
164 'show_existing_comments' => false,
165 'allowed_comment_types' => array(),
166 'blocked_comment_types' => array(),
167 'settings_saved' => true,
168 'db_version' => $this->options['db_version']
169 ];
170 }
171 } else {
172 $this->options = get_option('disable_comments_options', array());
173 $not_configured = empty($this->options) || empty($this->options['settings_saved']);
174
175 if (is_multisite() && $not_configured && $this->sitewide_settings == '1') {
176 $this->options = get_site_option('disable_comments_options', array());
177 $this->options['is_network_options'] = true;
178 }
179 }
180
181
182 // If it looks like first run, check compat.
183 if (empty($this->options)) {
184 $this->check_compatibility();
185 }
186
187 $this->options['sitewide_settings'] = ($this->sitewide_settings == '1');
188
189 // Upgrade DB if necessary.
190 $this->check_db_upgrades();
191 $this->check_upgrades();
192
193 add_action('plugins_loaded', [$this, 'init_filters']);
194 add_action('wp_loaded', [$this, 'start_plugin_usage_tracking']);
195
196 // Add Site Health integration
197 add_filter('debug_information', array($this, 'add_site_health_info'));
198 }
199
200 /**
201 * Routing hint: is this request from the network-admin screen?
202 *
203 * During AJAX, WP's is_network_admin() is always false, so the JS
204 * appends ?is_network_admin=1 to ajaxurl (value set server-side in
205 * admin_enqueue_scripts via is_network_admin()). The GET param is
206 * client-supplied and therefore forgeable — never use this method
207 * alone for authorization. Always pair with can_network_admin_ajax_context()
208 * or an explicit current_user_can() check.
209 */
210 private function is_network_admin_ajax_context() {
211 if (!$this->networkactive) {
212 return false;
213 }
214 if (is_network_admin()) {
215 return true;
216 }
217 if (defined('DOING_AJAX') && DOING_AJAX && is_multisite() && isset($_GET['is_network_admin'])) {
218 $param = sanitize_text_field(wp_unslash($_GET['is_network_admin']));
219 return $param === '1';
220 }
221 return false;
222 }
223
224 /**
225 * Capability-gated network-admin context check.
226 *
227 * Returns true only when the request appears to come from the
228 * network-admin screen AND the current user holds
229 * manage_network_plugins. Safe for authorization decisions.
230 */
231 private function can_network_admin_ajax_context() {
232 if ($this->is_network_admin_ajax_context() && current_user_can('manage_network_plugins')) {
233 return true;
234 }
235
236 return false;
237 }
238
239 /**
240 * Enable CLI
241 * @since 2.0.0
242 */
243 public function enable_cli() {
244 require_once DC_PLUGIN_ROOT_PATH . "/includes/cli.php";
245 new Disable_Comment_Command($this);
246 }
247
248 /**
249 * Load the Abilities API integration.
250 *
251 * Guarded on the API being present so nothing changes on WordPress < 6.9,
252 * where these hooks never fire anyway.
253 *
254 * @since 2.8.0
255 * @return bool True when the integration is available and loaded.
256 */
257 private function load_abilities() {
258 if (!function_exists('wp_register_ability') || !function_exists('wp_register_ability_category')) {
259 return false;
260 }
261 require_once DC_PLUGIN_ROOT_PATH . '/includes/abilities.php';
262 return true;
263 }
264
265 /**
266 * Register the plugin's ability category with the Abilities API.
267 *
268 * @since 2.8.0
269 * @return void
270 */
271 public function register_ability_categories() {
272 if ($this->load_abilities()) {
273 disable_comments_register_ability_categories();
274 }
275 }
276
277 /**
278 * Register the plugin's abilities with the Abilities API.
279 *
280 * @since 2.8.0
281 * @return void
282 */
283 public function register_abilities() {
284 if ($this->load_abilities()) {
285 disable_comments_register_abilities();
286 }
287 }
288
289 public function admin_notice() {
290 if ($this->tracker instanceof DisableComments_Plugin_Tracker) {
291 if (isset($this->setup_notice_flag) && $this->setup_notice_flag === true) {
292 return;
293 }
294 $current_screen = get_current_screen()->id;
295 $has_caps = $this->networkactive && is_network_admin() ? current_user_can('manage_network_plugins') : current_user_can('manage_options');
296 // if( ! in_array( $current_screen, ['settings_page_disable_comments_settings', 'settings_page_disable_comments_settings-network']) && $has_caps ) {
297 if ($has_caps && in_array($current_screen, ['dashboard-network', 'dashboard'])) {
298 $this->tracker->notice();
299 }
300 }
301 }
302
303 public function start_plugin_usage_tracking() {
304 if ($this->networkactive && !$this->options['sitewide_settings']) {
305 $this->tracker = null;
306 return;
307 }
308 if (!class_exists('DisableComments_Plugin_Tracker')) {
309 include_once(DC_PLUGIN_ROOT_PATH . '/includes/class-plugin-usage-tracker.php');
310 }
311 $tracker = $this->tracker = DisableComments_Plugin_Tracker::get_instance(__FILE__, [
312 'opt_in' => true,
313 'goodbye_form' => true,
314 'item_id' => 'b0112c9030af6ba53de4'
315 ]);
316 $tracker->set_notice_options(array(
317 'notice' => __('Want to help make Disable Comments even better?', 'disable-comments'),
318 'extra_notice' => __('We collect non-sensitive diagnostic data and plugin usage information. Your site URL, WordPress & PHP version, plugins & themes and email address to send you the discount coupon. This data lets us make sure this plugin always stays compatible with the most popular plugins and themes. No spam, I promise.', 'disable-comments'),
319 ));
320 $tracker->init();
321 }
322
323 private function check_compatibility() {
324 if (version_compare($GLOBALS['wp_version'], '4.7', '<')) {
325 require_once(ABSPATH . 'wp-admin/includes/plugin.php');
326 deactivate_plugins(__FILE__);
327
328 // @phpcs:ignore WordPress.Security.NonceVerification.Recommended
329 if (isset($_GET['action']) && ($_GET['action'] == 'activate' || $_GET['action'] == 'error_scrape')) {
330 // translators: %s: WordPress version no.
331 exit(sprintf(esc_html__('Disable Comments requires WordPress version %s or greater.', 'disable-comments'), '4.7'));
332 }
333 }
334 }
335
336 private function check_db_upgrades() {
337 $old_ver = isset($this->options['db_version']) ? $this->options['db_version'] : 0;
338 if ($old_ver < self::DB_VERSION) {
339 if ($old_ver < 2) {
340 // upgrade options from version 0.2.1 or earlier to 0.3.
341 $this->options['disabled_post_types'] = get_option('disable_comments_post_types', array());
342 delete_option('disable_comments_post_types');
343 }
344 if ($old_ver < 5) {
345 // simple is beautiful - remove multiple settings in favour of one.
346 $this->options['remove_everywhere'] = isset($this->options['remove_admin_menu_comments']) ? $this->options['remove_admin_menu_comments'] : false;
347 foreach (array('remove_admin_menu_comments', 'remove_admin_bar_comments', 'remove_recent_comments', 'remove_discussion', 'remove_rc_widget') as $v) {
348 unset($this->options[$v]);
349 }
350 }
351 if ($old_ver < 7 && function_exists('get_sites')) {
352 $this->options['disabled_sites'] = [];
353 $dc_options = get_site_option('disable_comments_options', array());
354
355 foreach (get_sites(['number' => 0, 'fields' => 'ids']) as $blog_id) {
356 if (isset($dc_options['disabled_sites'])) {
357 $this->options['disabled_sites']["site_$blog_id"] = in_array($blog_id, $dc_options['disabled_sites']);
358 } else {
359 $this->options['disabled_sites']["site_$blog_id"] = true;
360 }
361 }
362 $this->options['disabled_sites'] = $this->get_disabled_sites();
363 }
364
365 if ($old_ver < 8) {
366 // Add new show_existing_comments option with default value false
367 // This maintains backward compatibility - existing behavior is preserved
368 $this->options['show_existing_comments'] = false;
369 }
370
371 foreach (array('remove_everywhere', 'extra_post_types', 'show_existing_comments') as $v) {
372 if (!isset($this->options[$v])) {
373 $this->options[$v] = false;
374 }
375 }
376
377 $this->options['db_version'] = self::DB_VERSION;
378 $this->update_options($this->networkactive);
379 }
380 }
381
382 public function check_upgrades() {
383 $dc_version = get_option('disable_comment_version');
384 if (version_compare($dc_version, '2.3.1', '<')) {
385 if ($this->is_remove_everywhere()) {
386 update_option('show_avatars', true);
387 }
388 }
389 if (!$dc_version || $dc_version != DC_VERSION) {
390 update_option('disable_comment_version', DC_VERSION);
391 }
392 }
393
394 private function update_options($is_network_ctx = false) {
395 if ($this->networkactive && $is_network_ctx) {
396 update_site_option('disable_comments_options', $this->options);
397 } else {
398 update_option('disable_comments_options', $this->options);
399 }
400 }
401
402 /**
403 * Read the settings as they are stored, for the store a write would target.
404 *
405 * $this->options is the *effective* configuration for the current request,
406 * which is not always what is on disk. On a network-wide install the
407 * constructor replaces it with a blank, everything-enabled config whenever
408 * the current blog is not among the network's disabled_sites and the
409 * request is not a network admin one - which is every WP-CLI request, since
410 * is_network_admin_ajax_context() has no GET parameter to read there.
411 *
412 * Export would then write that blank out as if it were the network's
413 * settings, and import would take it as the baseline for every field the
414 * incoming file omits - so a partial import of one flag would quietly reset
415 * the rest of the network to defaults. Both need the stored row.
416 *
417 * The routing deliberately mirrors update_options() line for line: an
418 * import's baseline has to be the same row the import will write, or the
419 * diff it reports is a diff against something else.
420 *
421 * @param bool $is_network_ctx Whether the operation targets network storage.
422 * @return array Stored settings, empty if nothing has been saved yet.
423 */
424 private function get_stored_settings($is_network_ctx = false) {
425 if ($this->networkactive && $is_network_ctx) {
426 return (array) get_site_option('disable_comments_options', array());
427 }
428
429 return (array) get_option('disable_comments_options', array());
430 }
431
432 /**
433 * Purges front-end page caches after a change to what the site renders.
434 *
435 * Disabling comments changes the HTML of every page that carries a comment
436 * form or count, but a full-page cache keeps serving the old markup until
437 * each entry expires. On a live nginx FastCGI host this was reproducible:
438 * after saving, `x-cache: HIT` responses still contained the comment form
439 * while a cache-busted request did not. Comments really were off — visitors
440 * just could not tell.
441 *
442 * Called from the save and delete handlers rather than from
443 * update_options(), deliberately. Those handlers run on `wp_ajax_*` or
444 * WP-CLI, long after `plugins_loaded`, so every cache plugin has already
445 * registered its listeners. update_options() is also reached from
446 * check_db_upgrades() during plugin construction, where firing these would
447 * be too early for anything to hear them.
448 *
449 * Only *page* caches are purged. The object cache (Redis/Memcached) is left
450 * alone on purpose: settings are read through options that WordPress already
451 * invalidates on write, so flushing a shared object cache would stampede a
452 * busy site's origin for no benefit.
453 *
454 * Each integration is guarded — a caching plugin that renames or drops its
455 * API must never turn "settings saved" into a fatal error.
456 *
457 * @since 2.8.0
458 * @return void
459 */
460 public function purge_page_caches($blog_ids = array()) {
461 // On a network the purge has to run *inside* each affected site. Several
462 // integrations (WP Rocket, SiteGround Optimizer, W3 Total Cache) only
463 // clear the site they are called from, so purging once from the network
464 // admin would leave every subsite serving deleted comments, stale counts,
465 // or an old comment form.
466 if (!empty($blog_ids) && is_multisite() && function_exists('switch_to_blog')) {
467 foreach (array_unique(array_map('intval', (array) $blog_ids)) as $blog_id) {
468 switch_to_blog($blog_id);
469 $this->purge_current_site_page_caches();
470 restore_current_blog();
471 }
472 return;
473 }
474
475 $this->purge_current_site_page_caches();
476 }
477
478 /**
479 * Purges page caches for the site that is currently switched in.
480 *
481 * Split out from purge_page_caches() so the network loop can reuse it
482 * without re-entering the switching logic.
483 *
484 * @since 2.8.0
485 * @return void
486 */
487 private function purge_current_site_page_caches() {
488 /**
489 * Fires when Disable Comments has changed what the front end renders.
490 *
491 * On a network this fires once per affected site, with that site
492 * switched in, so `get_current_blog_id()` inside the handler is the site
493 * being purged. Hosts, caching plugins, and CDN integrations can hook
494 * this to clear their own layer. Fired before the bundled integrations
495 * below so a handler can act first.
496 *
497 * @since 2.8.0
498 */
499 do_action('disable_comments_purge_caches');
500
501 if (function_exists('wp_cache_clear_cache')) {
502 wp_cache_clear_cache(); // WP Super Cache.
503 }
504 if (function_exists('w3tc_flush_posts')) {
505 w3tc_flush_posts(); // W3 Total Cache — page cache only, not the whole stack.
506 }
507 if (function_exists('rocket_clean_domain')) {
508 rocket_clean_domain(); // WP Rocket.
509 }
510 if (function_exists('sg_cachepress_purge_cache')) {
511 sg_cachepress_purge_cache(); // SiteGround Optimizer.
512 }
513
514 // Action-based integrations. do_action() with no listener is a no-op, so
515 // these are safe to fire unconditionally and stay correct for a plugin
516 // that registers its listener late.
517 do_action('litespeed_purge_all'); // LiteSpeed Cache.
518 do_action('rt_nginx_helper_purge_all'); // Nginx Helper.
519 do_action('breeze_clear_all_cache'); // Breeze.
520 do_action('wphb_clear_page_cache'); // Hummingbird.
521 }
522
523 /**
524 * Sites whose rendered pages a settings save has just invalidated.
525 *
526 * A save from the network admin changes what every site in the network
527 * renders, so every site's page cache is stale — not just the one the
528 * request happened to run on. Outside a network context this is the current
529 * site alone, which purge_page_caches() handles by default.
530 *
531 * @since 2.8.0
532 * @param bool $is_network_ctx Whether the save came from a network admin screen.
533 * @return array Blog IDs to purge. Empty means "just the current site".
534 */
535 private function get_purge_blog_ids($is_network_ctx) {
536 if (!$is_network_ctx || !is_multisite() || !function_exists('get_sites')) {
537 return array();
538 }
539
540 $blog_ids = get_sites(array('number' => 0, 'fields' => 'ids'));
541
542 /**
543 * Filters the sites purged after a network-wide settings change.
544 *
545 * Defaults to every site in the network, which is correct but O(sites).
546 * A very large network whose cache layer already purges network-wide
547 * from a single call can narrow this list.
548 *
549 * @since 2.8.0
550 * @param array $blog_ids Blog IDs about to be purged.
551 */
552 return (array) apply_filters('disable_comments_purge_blog_ids', $blog_ids);
553 }
554
555 public function get_disabled_sites($default = false) {
556 $disabled_sites = ['all' => true];
557 foreach (get_sites(['number' => 0, 'fields' => 'ids']) as $blog_id) {
558 $disabled_sites["site_{$blog_id}"] = true;
559 }
560 if ($default) {
561 return $disabled_sites;
562 }
563
564 $this->options['disabled_sites'] = isset($this->options['disabled_sites']) ? $this->options['disabled_sites'] : [];
565 $this->options['disabled_sites'] = wp_parse_args($this->options['disabled_sites'], $disabled_sites);
566 $disabled_sites = $this->options['disabled_sites'];
567 unset($disabled_sites['all']);
568 if (in_array(false, $disabled_sites)) {
569 $this->options['disabled_sites']['all'] = false;
570 } else {
571 $this->options['disabled_sites']['all'] = true;
572 }
573 return $this->options['disabled_sites'];
574 }
575
576 // public function get_disabled_count(){
577 // $disabled_sites = isset($this->options['disabled_sites']) ? $this->options['disabled_sites'] : [];
578 // unset($disabled_sites['all']);
579 // return array_sum($disabled_sites);
580 // }
581
582 /**
583 * Get an array of disabled post type.
584 */
585 public function get_disabled_post_types() {
586 $types = $this->options['disabled_post_types'];
587 // Not all extra_post_types might be registered on this particular site.
588 if ($this->networkactive && !empty($this->options['extra_post_types'])) {
589 foreach ((array) $this->options['extra_post_types'] as $extra) {
590 if (post_type_exists($extra)) {
591 $types[] = $extra;
592 }
593 }
594 }
595 return $types;
596 }
597
598 /**
599 * Check whether comments have been disabled on a given post type.
600 */
601 private function is_exclude_by_role() {
602 if (!empty($this->options['enable_exclude_by_role']) && !empty($this->options['exclude_by_role'])) {
603 if (is_user_logged_in()) {
604 $user = wp_get_current_user();
605 $roles = (array) $user->roles;
606 $diff = array_intersect($this->options['exclude_by_role'], $roles);
607 if (count($diff) || (in_array("administrator", $this->options['exclude_by_role']) && is_super_admin())) {
608 return true;
609 }
610 } else if (in_array('logged-out-users', $this->options['exclude_by_role'])) {
611 return true;
612 }
613 }
614 return false;
615 }
616 /**
617 * Endpoint-level comment blocking state.
618 *
619 * These settings are independent of the post-type configuration: either can
620 * block comments over its transport while post types remain untouched. A
621 * consumer that only inspects post-type settings would report comments as
622 * fully enabled while REST comment creation returns 403.
623 *
624 * REST blocking has two sources — the dedicated toggle *and* global
625 * "disable everywhere" mode, whose branch in init_filters() installs the
626 * same rest_pre_dispatch/rest_endpoints/rest_comment_query filters. Either
627 * one results in a 403 for non-allowlisted comment requests, so both count.
628 * XML-RPC has only the dedicated toggle; global mode does not touch it.
629 *
630 * Reported role-independently, matching how the rest of the site's
631 * configuration is described.
632 *
633 * @since 2.8.0
634 * @return array {
635 * @type bool $rest Whether REST API comment endpoints are blocked.
636 * @type bool $xmlrpc Whether XML-RPC comment methods are removed.
637 * }
638 */
639 public function get_endpoint_blocking_state() {
640 $rest_toggle = isset($this->options['remove_rest_API_comments']) && intval($this->options['remove_rest_API_comments']) === 1;
641 return array(
642 'rest' => $rest_toggle || $this->is_remove_everywhere_configured(),
643 'xmlrpc' => isset($this->options['remove_xmlrpc_comments']) && intval($this->options['remove_xmlrpc_comments']) === 1,
644 );
645 }
646
647 /**
648 * Comment types that stay enabled even when comments are disabled.
649 *
650 * The allowlist (e.g. WordPress 6.9+ "note" comments) is preserved in
651 * comment queries, counted separately, and permitted through REST even in
652 * "disable everywhere" mode. Consumers describing the site's comment state
653 * must disclose it, otherwise "comments are disabled" reads as absolute
654 * when it is not.
655 *
656 * @since 2.8.0
657 * @return array List of allowed comment type slugs.
658 */
659 public function get_allowed_comment_types_list() {
660 $allowed = $this->get_allowed_comment_types();
661 return is_array($allowed) ? array_values($allowed) : array();
662 }
663
664 /**
665 * Whether the site is *configured* to disable comments everywhere.
666 *
667 * Unlike is_remove_everywhere(), this reports the stored setting regardless
668 * of the current user's role exemption. Consumers that describe the site's
669 * configuration (such as the Abilities API integration) need the
670 * role-independent value; consumers deciding whether to filter a given
671 * request must keep using is_remove_everywhere().
672 *
673 * @since 2.8.0
674 * @return bool True when the global "disable everywhere" setting is on.
675 */
676 /**
677 * Whether every comment-capable post type on this site is actually closed.
678 *
679 * "Every public post type is ticked" is not the same as "comments are off
680 * everywhere". get_all_post_types() — and so the settings screen — only
681 * lists `public` post types, but a non-public post type can support comments
682 * too. Tick every box and that type stays open; switch on the global setting
683 * and it is closed. Only the second is genuinely site-wide.
684 *
685 * Detected by looking for any post type that *still* supports comments and
686 * is not in the disabled list. The plugin removes comment support from the
687 * types it closes, so whatever still supports comments is precisely what it
688 * has not closed — including non-public and late-registered types the
689 * settings screen never shows.
690 *
691 * Not valid for the global setting: under "remove everywhere" the plugin
692 * closes types without necessarily having stripped support from ones
693 * registered after its filters ran. Callers must check
694 * is_remove_everywhere_configured() first.
695 *
696 * @since 2.8.0
697 * @return bool True when no comment-capable post type is left open.
698 */
699 /**
700 * Disabled post types, limited to ones that actually exist right now.
701 *
702 * The stored selection outlives the post types in it. Disable comments on a
703 * CPT, then deactivate the plugin that registered it, and the slug stays in
704 * the option forever — so a status report would advertise a post type the
705 * site no longer has, while get_all_post_types() correctly omits it.
706 *
707 * FOR REPORTING ONLY. Never use this to decide which types to filter:
708 * get_disabled_post_types() is consulted while filters are being installed,
709 * before CPTs have registered on `init`, and dropping unregistered types
710 * there would leave comments open on every custom post type.
711 *
712 * @since 2.8.0
713 * @return array Disabled post type slugs that are currently registered.
714 */
715 public function get_disabled_post_types_registered() {
716 $types = $this->get_disabled_post_types();
717 $types = is_array($types) ? $types : array();
718
719 $existing = array();
720 foreach ($types as $type) {
721 if (post_type_exists($type)) {
722 $existing[] = $type;
723 }
724 }
725
726 return array_values($existing);
727 }
728
729 public function is_every_comment_capable_type_disabled() {
730 $disabled = $this->get_disabled_post_types();
731 $disabled = is_array($disabled) ? $disabled : array();
732
733 foreach (get_post_types(array(), 'names') as $post_type) {
734 if (post_type_supports($post_type, 'comments') && !in_array($post_type, $disabled, true)) {
735 return false;
736 }
737 }
738
739 return true;
740 }
741
742 public function is_remove_everywhere_configured() {
743 return !empty($this->options['remove_everywhere']);
744 }
745
746 /**
747 * Role-exclusion state for the current request.
748 *
749 * Role exclusion is a per-user override: when the current user matches an
750 * excluded role, comments are left open for them even though the site is
751 * configured to disable them. Consumers that report configuration (such as
752 * the Abilities API integration) need to disclose this, otherwise the
753 * reported status is misleading for exempt users.
754 *
755 * @since 2.8.0
756 * @return array {
757 * @type bool $enabled Whether role-based exclusion is configured at all.
758 * @type bool $excluded Whether the *current* user is exempt.
759 * }
760 */
761 public function get_role_exclusion_state() {
762 $enabled = !empty($this->options['enable_exclude_by_role']) && !empty($this->options['exclude_by_role']);
763 return array(
764 'enabled' => (bool) $enabled,
765 'excluded' => (bool) $this->is_exclude_by_role(),
766 );
767 }
768
769 private function is_remove_everywhere() {
770 if ($this->is_exclude_by_role()) {
771 return false;
772 }
773 if (isset($this->options['remove_everywhere'])) {
774 return $this->options['remove_everywhere'];
775 }
776 return false;
777 }
778
779 /**
780 * Check whether comments have been disabled on a given post type.
781 */
782 private function is_post_type_disabled($type) {
783 if ($this->is_exclude_by_role()) {
784 return false;
785 }
786 return $type && in_array($type, $this->get_disabled_post_types());
787 }
788
789 /**
790 * Is the conditional-rules layer switched on and carrying anything?
791 *
792 * Cheap enough to call from a per-post filter: it only looks at options
793 * already in memory, and short-circuits every rule evaluation when the
794 * feature is off - which is the overwhelmingly common case.
795 *
796 * @return bool
797 */
798 public function has_conditional_rules() {
799 if (empty($this->options['enable_conditional_rules'])) {
800 return false;
801 }
802
803 return !empty($this->options['conditional_rules']) || $this->get_auto_close_days() > 0;
804 }
805
806 /**
807 * Configured auto-close window in days, or 0 when disabled.
808 *
809 * @return int
810 */
811 public function get_auto_close_days() {
812 if (empty($this->options['enable_conditional_rules'])) {
813 return 0;
814 }
815
816 $days = isset($this->options['auto_close_days']) ? (int) $this->options['auto_close_days'] : 0;
817
818 return $days > 0 ? $days : 0;
819 }
820
821 /**
822 * The saved conditional rules, always as a list.
823 *
824 * @return array
825 */
826 public function get_conditional_rules() {
827 if (empty($this->options['enable_conditional_rules']) || empty($this->options['conditional_rules'])) {
828 return array();
829 }
830
831 return array_values((array) $this->options['conditional_rules']);
832 }
833
834 /**
835 * Is any active rule an "enable" exception?
836 *
837 * Asked by anything that wants to state comments are off everywhere. One
838 * enable rule means some post is deliberately still open, so the claim
839 * cannot be made — whether or not the rule currently matches anything,
840 * because "no post accepts comments" and "an exception exists that may
841 * match tomorrow" should not both be reported as a site-wide shutdown.
842 *
843 * @since 2.9.0
844 * @return bool
845 */
846 public function has_enable_conditional_rules() {
847 foreach ($this->get_conditional_rules() as $rule) {
848 if (isset($rule['action']) && 'enable' === $rule['action']) {
849 return true;
850 }
851 }
852
853 return false;
854 }
855
856 /**
857 * Does one rule apply to this post?
858 *
859 * Existence is checked here rather than at save time: a taxonomy from a
860 * temporarily deactivated plugin should stop matching, not be silently
861 * dropped from the stored configuration.
862 *
863 * @param array $rule A sanitized rule.
864 * @param int $post_id Post being evaluated.
865 * @return bool
866 */
867 private function conditional_rule_matches($rule, $post_id) {
868 $type = isset($rule['type']) ? $rule['type'] : '';
869
870 if ('taxonomy' === $type) {
871 $taxonomy = isset($rule['taxonomy']) ? $rule['taxonomy'] : '';
872 $terms = isset($rule['terms']) ? array_filter((array) $rule['terms']) : array();
873
874 if ('' === $taxonomy || empty($terms) || !taxonomy_exists($taxonomy)) {
875 return false;
876 }
877
878 return (bool) has_term($terms, $taxonomy, $post_id);
879 }
880
881 if ('template' === $type) {
882 $template = isset($rule['template']) ? $rule['template'] : '';
883
884 if ('' === $template) {
885 return false;
886 }
887
888 return $template === get_page_template_slug($post_id);
889 }
890
891 return false;
892 }
893
894 /**
895 * Has this post passed the auto-close window?
896 *
897 * @param int $post_id Post being evaluated.
898 * @return bool
899 */
900 private function is_past_auto_close($post_id) {
901 $days = $this->get_auto_close_days();
902
903 if ($days < 1) {
904 return false;
905 }
906
907 $published = get_post_time('U', true, $post_id);
908
909 if (empty($published)) {
910 return false;
911 }
912
913 return (time() - (int) $published) > ($days * DAY_IN_SECONDS);
914 }
915
916 /**
917 * Resolve the conditional rules for one post.
918 *
919 * An "enable" rule is an exception and wins outright, so "disable
920 * everywhere except Announcements" is expressible without enumerating
921 * every other term. An exception also survives the auto-close window -
922 * a post you deliberately kept open stays open.
923 *
924 * @param int $post_id Post being evaluated.
925 * @return string 'enable', 'disable', or '' when no rule applies.
926 */
927 private function match_conditional_rules($post_id) {
928 $result = '';
929
930 foreach ($this->get_conditional_rules() as $rule) {
931 if (!$this->conditional_rule_matches($rule, $post_id)) {
932 continue;
933 }
934
935 if (isset($rule['action']) && 'enable' === $rule['action']) {
936 return 'enable';
937 }
938
939 $result = 'disable';
940 }
941
942 if ('' === $result && $this->is_past_auto_close($post_id)) {
943 $result = 'disable';
944 }
945
946 return $result;
947 }
948
949 /**
950 * Are comments disabled for this specific post?
951 *
952 * The base answer comes from the global toggle and the post-type list;
953 * conditional rules then override it. This is the single decision the
954 * per-post filters ask, so comments_open(), the comment count and the
955 * existing-comment list can never disagree with each other.
956 *
957 * @param int $post_id Post being evaluated.
958 * @return bool
959 */
960 public function is_disabled_for_post($post_id) {
961 if ($this->is_exclude_by_role()) {
962 return false;
963 }
964
965 $disabled = $this->is_remove_everywhere() || $this->is_post_type_disabled(get_post_type($post_id));
966
967 if (!$this->has_conditional_rules()) {
968 return $disabled;
969 }
970
971 $match = $this->match_conditional_rules($post_id);
972
973 if ('enable' === $match) {
974 return false;
975 }
976
977 if ('disable' === $match) {
978 return true;
979 }
980
981 return $disabled;
982 }
983
984 /**
985 * Normalise submitted conditional rules.
986 *
987 * Anything that does not describe a complete, actionable rule is dropped
988 * rather than stored half-formed - a rule with no terms would otherwise
989 * sit in the options looking active while matching nothing.
990 *
991 * @param mixed $rules Raw submitted rules.
992 * @return array Sanitized list.
993 */
994 /**
995 * Rewrite taxonomy rules for travel between sites.
996 *
997 * Rules are stored with term IDs, which are rows in one site's terms table
998 * and mean nothing anywhere else. Writing them into an export file is worse
999 * than leaving the rules out: term 104 exists on the destination too, as
1000 * something entirely unrelated, so the import silently closes comments on
1001 * the wrong category while the one the operator meant stays open.
1002 *
1003 * Slugs are what travel. A term that cannot be resolved is dropped, and a
1004 * taxonomy rule left with no terms is dropped whole, because such a rule
1005 * matches nothing and would only sit in the UI looking meaningful.
1006 *
1007 * The export format is new in 2.9.0, so there are no term-ID files in the
1008 * wild to stay compatible with.
1009 *
1010 * @param array $rules Stored rules, taxonomy terms as IDs.
1011 * @return array Portable rules, taxonomy terms as slugs.
1012 */
1013 private function rules_to_portable($rules) {
1014 $out = array();
1015
1016 foreach ((array) $rules as $rule) {
1017 if (!isset($rule['type']) || 'taxonomy' !== $rule['type']) {
1018 $out[] = $rule;
1019 continue;
1020 }
1021
1022 $taxonomy = isset($rule['taxonomy']) ? $rule['taxonomy'] : '';
1023 $slugs = array();
1024
1025 foreach ((array) (isset($rule['terms']) ? $rule['terms'] : array()) as $term_id) {
1026 $term = get_term((int) $term_id, $taxonomy);
1027
1028 if ($term && !is_wp_error($term) && '' !== $term->slug) {
1029 $slugs[] = $term->slug;
1030 }
1031 }
1032
1033 if (empty($slugs)) {
1034 continue;
1035 }
1036
1037 $rule['terms'] = array_values(array_unique($slugs));
1038 $out[] = $rule;
1039 }
1040
1041 return $out;
1042 }
1043
1044 /**
1045 * Resolve a portable rule set against this site's terms.
1046 *
1047 * The inverse of rules_to_portable(). Runs before sanitize_conditional_rules(),
1048 * which intval()s terms and would turn every slug into 0.
1049 *
1050 * @param array $rules Portable rules, taxonomy terms as slugs.
1051 * @return array Rules for storage, taxonomy terms as IDs on this site.
1052 */
1053 private function rules_from_portable($rules) {
1054 $out = array();
1055
1056 foreach ((array) $rules as $rule) {
1057 if (!is_array($rule) || !isset($rule['type']) || 'taxonomy' !== $rule['type']) {
1058 $out[] = $rule;
1059 continue;
1060 }
1061
1062 $taxonomy = isset($rule['taxonomy']) ? sanitize_key($rule['taxonomy']) : '';
1063 $ids = array();
1064
1065 foreach ((array) (isset($rule['terms']) ? $rule['terms'] : array()) as $slug) {
1066 if (!is_scalar($slug)) {
1067 continue;
1068 }
1069
1070 $term = get_term_by('slug', sanitize_title((string) $slug), $taxonomy);
1071
1072 if ($term && !is_wp_error($term)) {
1073 $ids[] = (int) $term->term_id;
1074 }
1075 }
1076
1077 // A term the destination does not have cannot be matched, so the
1078 // rule is dropped rather than stored pointing at nothing.
1079 if (empty($ids)) {
1080 continue;
1081 }
1082
1083 $rule['terms'] = array_values(array_unique($ids));
1084 $out[] = $rule;
1085 }
1086
1087 return $out;
1088 }
1089
1090 private function sanitize_conditional_rules($rules) {
1091 $clean = array();
1092
1093 foreach ((array) $rules as $rule) {
1094 if (!is_array($rule)) {
1095 continue;
1096 }
1097
1098 $type = isset($rule['type']) ? sanitize_key($rule['type']) : '';
1099
1100 if ('taxonomy' !== $type && 'template' !== $type) {
1101 continue;
1102 }
1103
1104 $action = (isset($rule['action']) && 'enable' === $rule['action']) ? 'enable' : 'disable';
1105 $entry = array(
1106 'type' => $type,
1107 'action' => $action,
1108 );
1109
1110 if ('taxonomy' === $type) {
1111 $taxonomy = isset($rule['taxonomy']) ? sanitize_key($rule['taxonomy']) : '';
1112 $terms = isset($rule['terms']) ? array_values(array_filter(array_map('intval', (array) $rule['terms']))) : array();
1113
1114 if ('' === $taxonomy || empty($terms)) {
1115 continue;
1116 }
1117
1118 $entry['taxonomy'] = $taxonomy;
1119 $entry['terms'] = $terms;
1120 } else {
1121 $template = isset($rule['template']) ? sanitize_text_field($rule['template']) : '';
1122
1123 if ('' === $template) {
1124 continue;
1125 }
1126
1127 $entry['template'] = $template;
1128 }
1129
1130 $clean[] = $entry;
1131 }
1132
1133 return $clean;
1134 }
1135
1136 /**
1137 * Is blocked-attempt counting switched on?
1138 *
1139 * A very high traffic site may not want the shutdown write at all. The
1140 * filter is the supported way off; there is no setting, because a setting
1141 * to stop counting is a setting nobody would find.
1142 *
1143 * @return bool
1144 */
1145 public function blocked_stats_enabled() {
1146 return (bool) apply_filters('disable_comments_count_blocked_attempts', true);
1147 }
1148
1149 /**
1150 * Record one blocked attempt.
1151 *
1152 * In-memory only - see flush_blocked_stats() for the single write.
1153 *
1154 * @param string $vector One of 'comment', 'trackback', 'rest'.
1155 */
1156 public function count_blocked_attempt($vector) {
1157 if (!$this->blocked_stats_enabled()) {
1158 return;
1159 }
1160
1161 if (!isset($this->blocked_pending[$vector])) {
1162 $this->blocked_pending[$vector] = 0;
1163 }
1164
1165 $this->blocked_pending[$vector]++;
1166 }
1167
1168 /**
1169 * Blocked-attempt totals, normalised.
1170 *
1171 * @return array {
1172 * @type int $since Timestamp counting started from.
1173 * @type array $counts Vector => count.
1174 * }
1175 */
1176 public function get_blocked_stats() {
1177 $since = (int) get_option(self::BLOCKED_SINCE_OPTION, 0);
1178
1179 if ($since < 1) {
1180 // Falling back to time() without storing it made the start date
1181 // re-compute on every read, so a site that had not blocked
1182 // anything yet reported "counting since" today, then tomorrow, then
1183 // the day after - which reads as a counter that keeps resetting
1184 // itself. The first read is as good a moment as any to fix it, and
1185 // it is the first moment anybody could have noticed.
1186 $since = $this->stamp_blocked_since();
1187 }
1188
1189 $counts = array();
1190 foreach ($this->get_blocked_vectors() as $vector => $label) {
1191 $counts[$vector] = (int) get_option($this->get_blocked_vector_option($vector), 0);
1192 }
1193
1194 return array(
1195 'since' => $since,
1196 'counts' => $counts,
1197 'total' => array_sum($counts),
1198 );
1199 }
1200
1201 /**
1202 * Fix the moment counting started, and keep it.
1203 *
1204 * Returns the timestamp that is now stored, which is not always the one
1205 * this call proposed: a request that blocked something first has already
1206 * stamped it, and its timestamp is the true one.
1207 *
1208 * @return int Unix timestamp counting is measured from.
1209 */
1210 private function stamp_blocked_since() {
1211 $now = time();
1212
1213 // add_option(), not update_option(): this must never move a start date
1214 // that is already recorded, and its failure is how a race with
1215 // flush_blocked_stats() is detected rather than silently won.
1216 if (add_option(self::BLOCKED_SINCE_OPTION, $now, '', 'yes')) {
1217 return $now;
1218 }
1219
1220 $stored = (int) get_option(self::BLOCKED_SINCE_OPTION, 0);
1221
1222 if ($stored > 0) {
1223 return $stored;
1224 }
1225
1226 // A row exists holding something that is not a timestamp, so the read
1227 // above will keep falling through to "now" on every request - the same
1228 // drifting date, from corrupt data rather than a missing option. The
1229 // only value being overwritten here is one that never meant anything.
1230 update_option(self::BLOCKED_SINCE_OPTION, $now, 'yes');
1231
1232 return $now;
1233 }
1234
1235 /**
1236 * The vectors we can count, and what to call them.
1237 *
1238 * Deliberately does not include XML-RPC. wp.newComment is removed from the
1239 * server rather than rejected, so there is no dispatch to observe, and
1240 * pingback.ping is refused inside core's own method without firing anything
1241 * we can hang a counter on. A counter permanently reading zero would be
1242 * read as "no attempts", which is a different and false claim - so the
1243 * trackback vector is named for the wp-trackback.php flow it actually
1244 * covers rather than claiming pingbacks too.
1245 *
1246 * @return array Vector => translated label.
1247 */
1248 public function get_blocked_vectors() {
1249 return array(
1250 'comment' => __('Comment form submissions', 'disable-comments'),
1251 'trackback' => __('Trackbacks', 'disable-comments'),
1252 'rest' => __('REST API comment requests', 'disable-comments'),
1253 );
1254 }
1255
1256 /**
1257 * Write this request's blocked attempts, once.
1258 *
1259 * At most one write per request that actually blocked something - not per
1260 * request. The option is autoloaded because it is a handful of integers and
1261 * autoloading removes a read query from the path that has to write it.
1262 */
1263 public function flush_blocked_stats() {
1264 global $wpdb;
1265
1266 if (empty($this->blocked_pending)) {
1267 return;
1268 }
1269
1270 $pending = $this->blocked_pending;
1271 $this->blocked_pending = array();
1272
1273 // Read-modify-write would drop increments whenever two blocked requests
1274 // shut down together - which is exactly what a spam burst looks like,
1275 // and exactly when this number is supposed to be meaningful. Each
1276 // vector is its own numeric option incremented in one statement, so the
1277 // database does the addition and concurrent writers cannot clobber each
1278 // other.
1279 foreach ($pending as $vector => $increment) {
1280 $option = $this->get_blocked_vector_option($vector);
1281
1282 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
1283 $updated = $wpdb->query(
1284 $wpdb->prepare(
1285 "UPDATE $wpdb->options SET option_value = option_value + %d WHERE option_name = %s",
1286 (int) $increment,
1287 $option
1288 )
1289 );
1290
1291 if (!$updated) {
1292 // No row yet. Two requests racing to be the first would both
1293 // land here and only one add_option() can win the unique
1294 // option_name index, silently losing the other's increment - so
1295 // the loser retries the UPDATE against the row the winner just
1296 // created rather than dropping its count.
1297 if (!add_option($option, (int) $increment, '', 'yes')) {
1298 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
1299 $wpdb->query(
1300 $wpdb->prepare(
1301 "UPDATE $wpdb->options SET option_value = option_value + %d WHERE option_name = %s",
1302 (int) $increment,
1303 $option
1304 )
1305 );
1306 }
1307 }
1308
1309 wp_cache_delete($option, 'options');
1310 }
1311
1312 // Alloptions caches the whole set, so the individual deletes above are
1313 // not enough on a site using it.
1314 wp_cache_delete('alloptions', 'options');
1315
1316 if (!get_option(self::BLOCKED_SINCE_OPTION)) {
1317 add_option(self::BLOCKED_SINCE_OPTION, time(), '', 'yes');
1318 }
1319 }
1320
1321 /**
1322 * Option name holding one vector's running total.
1323 *
1324 * @param string $vector Vector key.
1325 * @return string
1326 */
1327 private function get_blocked_vector_option($vector) {
1328 return self::BLOCKED_STATS_OPTION . '_' . $vector;
1329 }
1330
1331 /**
1332 * Start counting again from now.
1333 */
1334 public function reset_blocked_stats() {
1335 $this->blocked_pending = array();
1336
1337 foreach (array_keys($this->get_blocked_vectors()) as $vector) {
1338 update_option($this->get_blocked_vector_option($vector), 0, 'yes');
1339 }
1340
1341 update_option(self::BLOCKED_SINCE_OPTION, time(), 'yes');
1342 }
1343
1344 /**
1345 * AJAX: reset the counters.
1346 *
1347 * Agencies hand sites over; the previous owner's numbers are noise.
1348 */
1349 public function reset_blocked_stats_ajax() {
1350 $nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');
1351
1352 if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
1353 wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
1354 }
1355
1356 if (!current_user_can('manage_options')) {
1357 wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
1358 }
1359
1360 $this->reset_blocked_stats();
1361
1362 wp_send_json_success($this->get_blocked_stats());
1363 }
1364
1365 /**
1366 * A comment submission core rejected because we closed comments.
1367 *
1368 * Fired from core's `comment_closed`, which runs only on a real POST to
1369 * wp-comments-post.php - never on render.
1370 */
1371 public function record_blocked_comment($post_id = 0) {
1372 // core fires this whenever a submission is refused, including posts
1373 // closed by their own comment_status or by another plugin. Crediting
1374 // ourselves for those would make the number meaningless.
1375 if (!$this->closed_by_this_plugin($post_id, 'comment')) {
1376 return;
1377 }
1378
1379 $this->count_blocked_attempt('comment');
1380 }
1381
1382 /**
1383 * Is this plugin the reason comments are closed on this post?
1384 *
1385 * @param int $post_id Post being submitted to.
1386 * @return bool
1387 */
1388 private function closed_by_this_plugin($post_id, $vector = 'comment') {
1389 $post_id = (int) $post_id;
1390
1391 if ($post_id < 1) {
1392 return false;
1393 }
1394
1395 if ($this->is_exclude_by_role()) {
1396 return false;
1397 }
1398
1399 // is_disabled_for_post(), not the base settings on their own. A
1400 // taxonomy, template or age rule can be the only reason a post is
1401 // closed, and asking only whether the global toggle or the post-type
1402 // list covers it meant every attempt blocked purely by a rule went
1403 // uncounted — invisible in the totals on exactly the sites configured
1404 // with rules and nothing else.
1405 if (!$this->is_disabled_for_post($post_id)) {
1406 return false;
1407 }
1408
1409 // The post is covered, but this particular post may already have
1410 // been closed on its own - by its own status, by core's
1411 // close_comments_for_old_posts, or by another plugin. Our filters would
1412 // have closed it anyway, so we cannot tell from the result alone; ask
1413 // the stored status, which is what we do not touch.
1414 //
1415 // The status has to match the attempt: a post with comment_status
1416 // closed and ping_status open rejects a comment on its own, and the
1417 // open ping status says nothing about that.
1418 $post = get_post($post_id);
1419
1420 if (!$post) {
1421 return false;
1422 }
1423
1424 $status = ('trackback' === $vector) ? $post->ping_status : $post->comment_status;
1425
1426 return 'open' === $status;
1427 }
1428
1429 /**
1430 * A trackback aimed at a post whose pings we closed.
1431 *
1432 * `pre_trackback_post` fires just before core's own pings_open() check, so
1433 * the same question is asked here to avoid counting ones that go through.
1434 *
1435 * @param int $post_id Target post.
1436 */
1437 public function record_blocked_trackback($post_id) {
1438 if (pings_open($post_id)) {
1439 return;
1440 }
1441
1442 // Same reasoning as record_blocked_comment(): pings closed natively, or
1443 // by somebody else, are not ours to claim.
1444 if (!$this->closed_by_this_plugin($post_id, 'trackback')) {
1445 return;
1446 }
1447
1448 $this->count_blocked_attempt('trackback');
1449 }
1450
1451 public function init_filters() {
1452 if ($this->blocked_stats_enabled()) {
1453 // Core fires these only on real submissions, never on render.
1454 // Priority/args: core passes the post id, which is what decides
1455 // whether this plugin is the reason it was refused.
1456 add_action('comment_closed', array($this, 'record_blocked_comment'), 10, 1);
1457 add_action('pre_trackback_post', array($this, 'record_blocked_trackback'));
1458 add_action('shutdown', array($this, 'flush_blocked_stats'));
1459 }
1460
1461 // Inert unless the request carries a token this site issued seconds
1462 // ago, so an ordinary visitor never reaches any of it.
1463 if (!is_admin()) {
1464 $this->maybe_arm_scan_probe();
1465 }
1466
1467 // These need to happen now.
1468 if ($this->is_remove_everywhere()) {
1469 add_action('widgets_init', array($this, 'disable_rc_widget'));
1470 add_filter('wp_headers', array($this, 'filter_wp_headers'));
1471 add_action('template_redirect', array($this, 'filter_query'), 9); // before redirect_canonical.
1472
1473 // Admin bar filtering has to happen here since WP 3.6.
1474 add_action('template_redirect', array($this, 'filter_admin_bar'));
1475 add_action('admin_init', array($this, 'filter_admin_bar'));
1476
1477 // Disable Comments REST API Endpoint (but allow notes)
1478 add_filter('rest_endpoints', array($this, 'filter_rest_endpoints'));
1479 add_filter('rest_pre_dispatch', array($this, 'filter_rest_comment_dispatch'), 10, 3);
1480 add_filter('rest_comment_query', array($this, 'filter_rest_comment_query'), 10, 2);
1481 }
1482
1483 // remove create comment via xmlrpc
1484 if (isset($this->options['remove_xmlrpc_comments']) && intval($this->options['remove_xmlrpc_comments']) === 1) {
1485 add_filter('xmlrpc_methods', array($this, 'disable_xmlrc_comments'));
1486 }
1487 // rest API Comment Block (but allow notes)
1488 if (isset($this->options['remove_rest_API_comments']) && intval($this->options['remove_rest_API_comments']) === 1) {
1489 add_filter('rest_endpoints', array($this, 'filter_rest_endpoints'));
1490 add_filter('rest_pre_insert_comment', array($this, 'disable_rest_API_comments'), 10, 2);
1491 add_filter('rest_pre_dispatch', array($this, 'filter_rest_comment_dispatch'), 10, 3);
1492 add_filter('rest_comment_query', array($this, 'filter_rest_comment_query'), 10, 2);
1493 }
1494
1495 // Comment types closed by the blocklist. Registered on the strength of
1496 // the stored option alone: whether the caller is exempt is decided
1497 // inside the callbacks, which run long after the current user has been
1498 // resolved. Note the deliberate absence of a comments_open() filter -
1499 // see reject_blocked_comment_type().
1500 if ($this->has_blocked_comment_types()) {
1501 add_filter('preprocess_comment', array($this, 'reject_blocked_comment_type'), 20);
1502 add_filter('rest_pre_insert_comment', array($this, 'reject_blocked_comment_type_rest'), 20, 2);
1503 }
1504
1505 // These can happen later.
1506 add_action('wp_loaded', array($this, 'init_wploaded_filters'));
1507 // Disable "Latest comments" block in Gutenberg.
1508 add_action('enqueue_block_editor_assets', array($this, 'filter_gutenberg_blocks'));
1509 // settings page assets
1510 add_action('admin_enqueue_scripts', array($this, 'settings_page_assets'));
1511
1512 if (!$this->networkactive || $this->options['sitewide_settings']) {
1513 add_filter('comment_status_links', function ($status_links) {
1514 $status_links['disable_comments'] = sprintf("<a href='" . $this->settings_page_url() . "'>%s</a>", __("Disable Comments", 'disable-comments'));
1515 return $status_links;
1516 });
1517 }
1518 }
1519
1520 public function init_wploaded_filters() {
1521 $disabled_post_types = $this->get_disabled_post_types();
1522 if (!empty($disabled_post_types) && !$this->is_exclude_by_role()) {
1523 foreach ($disabled_post_types as $type) {
1524 // we need to know what native support was for later.
1525 if (post_type_supports($type, 'comments')) {
1526 $this->modified_types[] = $type;
1527 // Keep comments support if show_existing_comments is enabled
1528 // or if there are allowed comment types that need to be displayed
1529 if (empty($this->options['show_existing_comments']) && !$this->has_allowed_comment_types()) {
1530 remove_post_type_support($type, 'comments');
1531 }
1532 remove_post_type_support($type, 'trackbacks');
1533 }
1534 }
1535 } elseif (is_admin() && !$this->is_configured()) {
1536 /**
1537 * It is possible that $disabled_post_types is empty if other
1538 * plugins have disabled comments. Hence we also check for
1539 * remove_everywhere. If you still get a warning you probably
1540 * shouldn't be using this plugin.
1541 */
1542 add_action('all_admin_notices', array($this, 'setup_notice'));
1543 }
1544
1545 // Conditional rules are evaluated per post, so they need these filters
1546 // even when no post type is ticked and the global toggle is off -
1547 // otherwise a site configured purely with rules silently does nothing.
1548 if ($this->is_remove_everywhere() || $this->has_conditional_rules() || (!empty($disabled_post_types) && !$this->is_exclude_by_role())) {
1549 add_filter('comments_array', array($this, 'filter_existing_comments'), 20, 2);
1550 add_filter('comments_open', array($this, 'filter_comment_status'), 20, 2);
1551 add_filter('pings_open', array($this, 'filter_comment_status'), 20, 2);
1552 add_filter('get_comments_number', array($this, 'filter_comments_number'), 20, 2);
1553 }
1554
1555 // A store that closed reviews should stop displaying the review UI, or
1556 // customers are invited to submit something the blocklist will refuse.
1557 // WooCommerce's own "enable reviews" switch is not usable for this: it
1558 // strips comments support from the product post type, and WooCommerce
1559 // then closes comments_open() for every product - taking the other
1560 // comment types on products down with it, which is precisely what this
1561 // setting exists to keep working.
1562 //
1563 // The reviews already in the database are left alone. Untick the
1564 // setting and they are all still there.
1565 if (
1566 $this->is_woocommerce_active()
1567 && $this->is_comment_type_blocked($this->get_review_comment_type())
1568 && !$this->is_exempt_from_blocklist()
1569 ) {
1570 // Before woocommerce_sort_product_tabs(), which runs at 99.
1571 add_filter('woocommerce_product_tabs', array($this, 'remove_woocommerce_reviews_tab'), 98);
1572 // The star rating in the product summary links to #reviews, which
1573 // the line above just removed from the page.
1574 remove_action('woocommerce_single_product_summary', 'woocommerce_template_single_rating', 10);
1575 }
1576
1577 // Filters for the admin only.
1578 if (is_admin()) {
1579 add_action('all_admin_notices', array($this, 'admin_notice'));
1580 if ($this->networkactive && is_network_admin()) {
1581 add_action('network_admin_menu', array($this, 'settings_menu'));
1582 add_action('network_admin_menu', array($this, 'tools_menu'));
1583 add_filter('network_admin_plugin_action_links', array($this, 'plugin_actions_links'), 10, 2);
1584 } elseif (!$this->networkactive || $this->options['sitewide_settings']) {
1585 add_action('admin_menu', array($this, 'settings_menu'));
1586 add_action('admin_menu', array($this, 'tools_menu'));
1587 add_filter('plugin_action_links', array($this, 'plugin_actions_links'), 10, 2);
1588 if (is_multisite()) { // We're on a multisite setup, but the plugin isn't network activated.
1589 register_deactivation_hook(__FILE__, array($this, 'single_site_deactivate'));
1590 }
1591 }
1592 add_action('admin_notices', array($this, 'discussion_notice'));
1593 // Gated on our own screens inside should_show_review_prompt(), not
1594 // by the hook - admin_notices fires everywhere. Network admin pages
1595 // fire network_admin_notices instead, so allowlisting the -network
1596 // screen ids achieves nothing without this second registration.
1597 add_action('admin_notices', array($this, 'review_prompt'));
1598 add_action('network_admin_notices', array($this, 'review_prompt'));
1599 add_filter('plugin_row_meta', array($this, 'set_plugin_meta'), 10, 2);
1600
1601 if ($this->is_remove_everywhere()) {
1602 add_action('admin_menu', array($this, 'filter_admin_menu'), 9999); // do this as late as possible.
1603 add_action('admin_print_styles-index.php', array($this, 'admin_css'));
1604 add_action('admin_print_styles-profile.php', array($this, 'admin_css'));
1605 add_action('wp_dashboard_setup', array($this, 'filter_dashboard'));
1606 add_filter('pre_option_default_pingback_flag', '__return_zero');
1607 }
1608 }
1609 // Filters for front end only.
1610 else {
1611 add_action('template_redirect', array($this, 'check_comment_template'));
1612
1613 if ($this->is_remove_everywhere()) {
1614 add_filter('feed_links_show_comments_feed', '__return_false');
1615 }
1616 }
1617 }
1618
1619 // public function get_option( $key, $default = false ){
1620 // return $this->networkactive ? get_site_option( $key, $default ) : get_option( $key, $default );
1621 // }
1622 // public function update_option( $option, $value ){
1623 // return $this->networkactive ? update_site_option( $option, $value ) : update_option( $option, $value );
1624 // }
1625 // public function delete_option( $option ){
1626 // return $this->networkactive ? delete_site_option( $option ) : delete_option( $option );
1627 // }
1628
1629 /**
1630 * Replace the theme's comment template with a blank one.
1631 * To prevent this, define DISABLE_COMMENTS_REMOVE_COMMENTS_TEMPLATE
1632 * and set it to True
1633 */
1634 public function check_comment_template() {
1635 // is_disabled_for_post(), not the global settings: an "enable" rule has
1636 // to reach here too. Otherwise comments_open() reports open, the theme
1637 // asks for the comments template, and we hand it the empty one - so the
1638 // advertised exception produces a post with no comment form on it.
1639 if (is_singular() && $this->is_disabled_for_post(get_queried_object_id())) {
1640 if (!defined('DISABLE_COMMENTS_REMOVE_COMMENTS_TEMPLATE') || DISABLE_COMMENTS_REMOVE_COMMENTS_TEMPLATE == true) {
1641 // Kill the comments template unless:
1642 // - show_existing_comments is enabled, OR
1643 // - there are allowed comment types that need to be displayed
1644 if (empty($this->options['show_existing_comments']) && !$this->has_allowed_comment_types()) {
1645 add_filter('comments_template', array($this, 'dummy_comments_template'), 20);
1646 }
1647 }
1648 // Remove comment-reply script for themes that include it indiscriminately.
1649 wp_deregister_script('comment-reply');
1650 // feed_links_extra inserts a comments RSS link.
1651 remove_action('wp_head', 'feed_links_extra', 3);
1652 }
1653 }
1654
1655 public function dummy_comments_template() {
1656 return dirname(__FILE__) . '/views/comments.php';
1657 }
1658
1659 public function is_xmlrpc_rest() {
1660 // remove create comment via xmlrpc
1661 if (isset($this->options['remove_xmlrpc_comments']) && intval($this->options['remove_xmlrpc_comments']) === 1) {
1662 return true;
1663 }
1664 // rest API Comment Block
1665 if (isset($this->options['remove_rest_API_comments']) && intval($this->options['remove_rest_API_comments']) === 1) {
1666 return true;
1667 }
1668 return false;
1669 }
1670
1671 /**
1672 * Remove the X-Pingback HTTP header
1673 */
1674 public function filter_wp_headers($headers) {
1675 unset($headers['X-Pingback']);
1676 return $headers;
1677 }
1678
1679 /**
1680 * remove method wp.newComment
1681 */
1682 public function disable_xmlrc_comments($methods) {
1683 unset($methods['wp.newComment']);
1684 return $methods;
1685 }
1686
1687 public function disable_rest_API_comments($prepared_comment, $request) {
1688 // Allow comment types in the allowlist (e.g., WordPress 6.9+ block notes)
1689 if ($this->is_allowed_comment_type_request($request)) {
1690 return $prepared_comment;
1691 }
1692
1693 // A conditional exception keeps comments open on this post, so a REST
1694 // submission to it must go through as well - blocking it here would
1695 // make comments_open() a lie for every API client.
1696 //
1697 // Only when the block comes from the global toggle or the post-type
1698 // list, though. "Disable comments via REST API" is an explicit
1699 // site-wide decision about the API itself, not about any one post, so a
1700 // per-post exception must not quietly switch it back on.
1701 if ($this->rest_blocking_is_conditional()) {
1702 $post_id = $this->get_request_post_id($request, $prepared_comment);
1703 if ($post_id && !$this->is_disabled_for_post($post_id)) {
1704 return $prepared_comment;
1705 }
1706 }
1707
1708
1709 $this->count_blocked_attempt('rest');
1710
1711 return;
1712 }
1713
1714 /**
1715 * Get the list of allowed comment types from settings
1716 *
1717 * @return array Array of allowed comment types
1718 */
1719 private function get_allowed_comment_types() {
1720 if (!isset($this->options['allowed_comment_types']) || !is_array($this->options['allowed_comment_types'])) {
1721 return array(); // Default: all special comment types disabled
1722 }
1723
1724 // A type ticked on both lists is a contradiction: "keep this working
1725 // where comments are off" and "close this where comments are on". The
1726 // blocklist wins - this is a plugin for switching comments off, so the
1727 // restrictive reading is the safe one. Resolving it in the single
1728 // reader means the delete tool, the REST allowance and the status
1729 // report cannot end up disagreeing with each other.
1730 $blocked = $this->get_blocked_comment_types();
1731 if (empty($blocked)) {
1732 return $this->options['allowed_comment_types'];
1733 }
1734
1735 return array_values(array_diff($this->options['allowed_comment_types'], $blocked));
1736 }
1737
1738 /**
1739 * Check if any comment types are enabled in the allowlist
1740 *
1741 * @return bool True if there are allowed comment types, false otherwise
1742 */
1743 private function has_allowed_comment_types() {
1744 $allowed_types = $this->get_allowed_comment_types();
1745 return !empty($allowed_types);
1746 }
1747
1748 /**
1749 * Check if a specific comment type is allowed (enabled in the allowlist)
1750 *
1751 * @param string $comment_type The comment type to check
1752 * @return bool True if the comment type is allowed, false otherwise
1753 */
1754 private function is_comment_type_allowed($comment_type) {
1755 $allowed_types = $this->get_allowed_comment_types();
1756 return in_array($comment_type, $allowed_types, true);
1757 }
1758
1759 /**
1760 * Get the list of blocked comment types from settings
1761 *
1762 * The blocklist is the mirror image of the allowlist. The allowlist keeps
1763 * named types working where comments are off; the blocklist closes named
1764 * types where comments are on. Both exist because comments_open() is one
1765 * boolean per post and cannot split a comment type from the post type
1766 * carrying it - a WooCommerce store that wants product reviews closed while
1767 * other comments on products stay open has no way to say so otherwise.
1768 *
1769 * @since 2.9.1
1770 * @return array Array of blocked comment types
1771 */
1772 private function get_blocked_comment_types() {
1773 if (!isset($this->options['blocked_comment_types']) || !is_array($this->options['blocked_comment_types'])) {
1774 return array(); // Default: nothing is closed by type
1775 }
1776 return $this->options['blocked_comment_types'];
1777 }
1778
1779 /**
1780 * Check if any comment types are closed by the blocklist
1781 *
1782 * @since 2.9.1
1783 * @return bool True if there are blocked comment types, false otherwise
1784 */
1785 private function has_blocked_comment_types() {
1786 $blocked_types = $this->get_blocked_comment_types();
1787 return !empty($blocked_types);
1788 }
1789
1790 /**
1791 * Check if a specific comment type is closed by the blocklist
1792 *
1793 * @since 2.9.1
1794 * @param string $comment_type The comment type to check
1795 * @return bool True if the comment type is blocked, false otherwise
1796 */
1797 private function is_comment_type_blocked($comment_type) {
1798 $blocked_types = $this->get_blocked_comment_types();
1799 return in_array($comment_type, $blocked_types, true);
1800 }
1801
1802 /**
1803 * Comment types that are closed even where comments are open.
1804 *
1805 * The counterpart to get_allowed_comment_types_list(). Consumers describing
1806 * the site's comment state must disclose it: "comments are open on
1807 * products" reads as absolute when it is not, and an agent that submits a
1808 * review on the strength of it gets a 403 it could have predicted.
1809 *
1810 * @since 2.9.1
1811 * @return array List of blocked comment type slugs.
1812 */
1813 public function get_blocked_comment_types_list() {
1814 $blocked = $this->get_blocked_comment_types();
1815 return is_array($blocked) ? array_values($blocked) : array();
1816 }
1817
1818 /**
1819 * The effective type of a comment being submitted.
1820 *
1821 * WordPress has written '' for the default type historically and 'comment'
1822 * since 5.5, and both still arrive. Normalising here means the blocklist
1823 * compares like with like no matter which one a caller sent.
1824 *
1825 * @since 2.9.1
1826 * @param mixed $comment_type Raw comment type from a submission payload.
1827 * @return string
1828 */
1829 private function normalize_comment_type($comment_type) {
1830 $comment_type = is_string($comment_type) ? trim($comment_type) : '';
1831 return ('' === $comment_type) ? 'comment' : $comment_type;
1832 }
1833
1834 /**
1835 * Is this caller exempt from the blocklist?
1836 *
1837 * Two exemptions, both deliberate:
1838 *
1839 * - The plugin's own role exclusion. Someone the site has exempted from
1840 * comment disabling is exempt from this too, or the setting would mean
1841 * something different depending on which screen it is read from.
1842 * - Anyone who can moderate comments. Closing a comment type is a statement
1843 * about what the public may submit, not about what a moderator may add by
1844 * hand: the rest of the plugin leaves back-end comment creation alone,
1845 * and wp_die()-ing the Reply button on wp-admin's comment screen would be
1846 * a bug, not a feature. Such a user can untick the setting anyway.
1847 *
1848 * @since 2.9.1
1849 * @return bool
1850 */
1851 private function is_exempt_from_blocklist() {
1852 if ($this->is_exclude_by_role()) {
1853 return true;
1854 }
1855
1856 return function_exists('current_user_can') && current_user_can('moderate_comments');
1857 }
1858
1859 /**
1860 * Refuse a comment whose type the site has closed.
1861 *
1862 * Runs on preprocess_comment at priority 20, which matters: WooCommerce
1863 * rewrites every default-type front-end comment on a product into a
1864 * 'review' from priority 1, so anything earlier would judge the type before
1865 * WooCommerce had finished deciding it.
1866 *
1867 * comments_open() is deliberately left alone. Closing it for the product
1868 * would close every other comment type on that product too, which is the
1869 * exact thing this setting exists to avoid.
1870 *
1871 * @since 2.9.1
1872 * @param array $commentdata Comment data on its way to wp_insert_comment().
1873 * @return array Unchanged when the type is not blocked; execution ends otherwise.
1874 */
1875 public function reject_blocked_comment_type($commentdata) {
1876 if (!is_array($commentdata) || !$this->has_blocked_comment_types()) {
1877 return $commentdata;
1878 }
1879
1880 $comment_type = $this->normalize_comment_type(isset($commentdata['comment_type']) ? $commentdata['comment_type'] : '');
1881
1882 if (!$this->is_comment_type_blocked($comment_type) || $this->is_exempt_from_blocklist()) {
1883 return $commentdata;
1884 }
1885
1886 $this->count_blocked_attempt('comment');
1887
1888 wp_die(
1889 esc_html__('Sorry, this kind of comment is no longer being accepted on this item.', 'disable-comments'),
1890 esc_html__('Comment Type Closed', 'disable-comments'),
1891 array('response' => 403)
1892 );
1893 }
1894
1895 /**
1896 * Refuse a REST-created comment whose type the site has closed.
1897 *
1898 * The REST controller calls wp_insert_comment() directly, so it never
1899 * reaches preprocess_comment and needs its own guard. Note that WooCommerce
1900 * does not rewrite types on this path either: a review arrives over REST
1901 * only when the caller asked for one by name.
1902 *
1903 * @since 2.9.1
1904 * @param array|mixed $prepared_comment Comment data prepared for the database.
1905 * @param WP_REST_Request $request The request.
1906 * @return array|WP_Error|mixed
1907 */
1908 public function reject_blocked_comment_type_rest($prepared_comment, $request) {
1909 // An earlier filter (disable_rest_API_comments) may already have
1910 // refused this request and returned nothing. Passing that through
1911 // unchanged keeps its refusal intact instead of masking it.
1912 if (!is_array($prepared_comment) || !$this->has_blocked_comment_types()) {
1913 return $prepared_comment;
1914 }
1915
1916 $comment_type = $this->normalize_comment_type(isset($prepared_comment['comment_type']) ? $prepared_comment['comment_type'] : '');
1917
1918 if (!$this->is_comment_type_blocked($comment_type) || $this->is_exempt_from_blocklist()) {
1919 return $prepared_comment;
1920 }
1921
1922 $this->count_blocked_attempt('rest');
1923
1924 return new WP_Error(
1925 'disable_comments_type_closed',
1926 __('Sorry, this kind of comment is no longer being accepted on this item.', 'disable-comments'),
1927 array('status' => 403)
1928 );
1929 }
1930
1931 /**
1932 * Drop WooCommerce's Reviews tab from the product page.
1933 *
1934 * Registered only while the review type is blocked. The tab renders both
1935 * the review list and the review form, so removing it is what stops a store
1936 * inviting a submission it is about to refuse.
1937 *
1938 * @since 2.9.1
1939 * @param array $tabs Product tabs.
1940 * @return array
1941 */
1942 public function remove_woocommerce_reviews_tab($tabs) {
1943 if (is_array($tabs)) {
1944 unset($tabs['reviews']);
1945 }
1946
1947 return $tabs;
1948 }
1949
1950 /**
1951 * Get available comment type options for the "Enable Certain Comment Types" UI
1952 *
1953 * This function returns a list of known special comment types that users can enable,
1954 * regardless of whether any comments of those types currently exist in the database.
1955 *
1956 * IMPORTANT: WordPress does not provide a formal API for registering or retrieving
1957 * comment types (unlike post types with get_post_types()). Comment types are simply
1958 * arbitrary string values stored in the wp_comments table. Therefore, we maintain
1959 * a curated list of known special comment types that plugins commonly use.
1960 *
1961 * This function returns only predefined known types plus any types added via the
1962 * 'disable_comments_known_comment_types' filter hook.
1963 *
1964 * @return array Associative array of comment_type => label
1965 */
1966 /**
1967 * Is WooCommerce running on this site?
1968 *
1969 * Every WooCommerce touch-point in this plugin goes through here. A store
1970 * plugin being absent, or renaming its API, must never turn "settings
1971 * saved" into a fatal - the same rule the cache integrations follow.
1972 *
1973 * @return bool
1974 */
1975 public function is_woocommerce_active() {
1976 return class_exists('WooCommerce');
1977 }
1978
1979 /**
1980 * The post type WooCommerce stores products under.
1981 *
1982 * @return string
1983 */
1984 public function get_product_post_type() {
1985 return 'product';
1986 }
1987
1988 /**
1989 * The comment type WooCommerce stores reviews under.
1990 *
1991 * @return string
1992 */
1993 public function get_review_comment_type() {
1994 return 'review';
1995 }
1996
1997 /**
1998 * How product reviews stand right now.
1999 *
2000 * Reviews are comments on the product post type, so they are governed by
2001 * the same settings as everything else - but a store owner does not think
2002 * of them that way, and looking for "reviews" in a list of post types
2003 * finds nothing. This is the answer in their language.
2004 *
2005 * Deliberately role-independent: the Abilities schema promises that every
2006 * field except excluded_for_current_user describes the site's
2007 * configuration, and this ability is only callable by settings-capable
2008 * users, who are exactly the ones a role exemption usually covers. Asking
2009 * "are reviews off?" must not answer "not for you".
2010 *
2011 * @return array {
2012 * @type bool $woocommerce_active True when WooCommerce is running.
2013 * @type bool $reviews_disabled True when customers cannot leave a review.
2014 * @type bool $reviews_allowlisted True when existing reviews stay readable.
2015 * @type bool $reviews_blocklisted True when the review type is closed on its own,
2016 * leaving other comments on products open.
2017 * @type string $disabled_by 'woocommerce', 'disable-comments', or ''.
2018 * }
2019 */
2020 public function get_product_review_status() {
2021 if (!$this->is_woocommerce_active()) {
2022 return array(
2023 'woocommerce_active' => false,
2024 'reviews_disabled' => false,
2025 'reviews_allowlisted' => false,
2026 'reviews_blocklisted' => false,
2027 'disabled_by' => '',
2028 );
2029 }
2030
2031 // WooCommerce has its own switch. With it off there are no reviews to
2032 // leave no matter what this plugin says, and reporting them "enabled"
2033 // would be simply false.
2034 $wc_enabled = ('yes' === get_option('woocommerce_enable_reviews', 'yes'));
2035
2036 // The blocklist closes the review type by name, without closing the
2037 // product. It is the only one of these three that leaves other comments
2038 // on products working, but for the question this function answers -
2039 // "can a customer leave a review?" - it counts exactly like the others.
2040 $blocklisted = $this->is_comment_type_blocked($this->get_review_comment_type());
2041
2042 $ours = $blocklisted
2043 || $this->is_remove_everywhere_configured()
2044 || in_array($this->get_product_post_type(), (array) $this->get_disabled_post_types(), true);
2045
2046 // The allowlist keeps allowlisted types readable and permits them
2047 // through REST. It does NOT reopen the comment form: comments_open()
2048 // is one boolean per post, so a product with comments disabled has no
2049 // review form regardless of the allowlist. Reporting these separately
2050 // is the only honest thing to do.
2051 $allowlisted = in_array($this->get_review_comment_type(), (array) $this->get_allowed_comment_types_list(), true);
2052
2053 if (!$wc_enabled) {
2054 $disabled_by = 'woocommerce';
2055 } elseif ($ours) {
2056 $disabled_by = 'disable-comments';
2057 } else {
2058 $disabled_by = '';
2059 }
2060
2061 return array(
2062 'woocommerce_active' => true,
2063 'reviews_disabled' => (bool) (!$wc_enabled || $ours),
2064 'reviews_allowlisted' => (bool) $allowlisted,
2065 'reviews_blocklisted' => (bool) $blocklisted,
2066 'disabled_by' => $disabled_by,
2067 );
2068 }
2069
2070 public function get_available_comment_type_options() {
2071 // Predefined known special comment types with descriptive labels
2072 // These are shown even if no comments of these types exist yet in the database
2073 //
2074 // Note: WordPress does not have a formal comment type registration API,
2075 // so this list is maintained manually based on common plugin usage.
2076 $known_types = array(
2077 'note' => __('Notes - WordPress 6.9+ (note)', 'disable-comments'),
2078 );
2079
2080 // Offer reviews before any exist. Discovery is driven off rows already
2081 // in the comments table, so a new store could not choose to keep
2082 // reviews working until somebody had already left one.
2083 if ($this->is_woocommerce_active()) {
2084 $known_types[$this->get_review_comment_type()] = __('Product reviews - WooCommerce (review)', 'disable-comments');
2085 }
2086
2087 /**
2088 * Filter the list of known comment types shown in the "Enable Certain Comment Types" UI
2089 *
2090 * Plugins can add their own comment types to this list so users can enable them
2091 * even before any comments of those types exist in the database.
2092 *
2093 * Example:
2094 * add_filter( 'disable_comments_known_comment_types', function( $types ) {
2095 * $types['my_custom_type'] = __( 'My Custom Comment Type', 'my-plugin' );
2096 * return $types;
2097 * } );
2098 *
2099 * @param array $known_types Associative array of comment_type => label
2100 */
2101 return apply_filters('disable_comments_known_comment_types', $known_types);
2102 }
2103
2104 /**
2105 * Check if a REST API request is for an allowed comment type
2106 *
2107 * @param WP_REST_Request $request The REST API request object
2108 * @return bool True if the request is for an allowed comment type, false otherwise
2109 */
2110 private function is_allowed_comment_type_request($request = null) {
2111 $comment_type = null;
2112
2113 // Check if we have a request object
2114 if (!$request) {
2115 // Check global $_REQUEST for type parameter
2116 if (isset($_REQUEST['type'])) {
2117 $comment_type = sanitize_text_field(wp_unslash($_REQUEST['type']));
2118 }
2119 // Check if we're in a REST API context
2120 elseif (defined('REST_REQUEST') && REST_REQUEST) {
2121 global $wp;
2122 if (isset($wp->query_vars['type'])) {
2123 $comment_type = sanitize_text_field($wp->query_vars['type']);
2124 }
2125 }
2126 } else {
2127 // Check the request object for type parameter
2128 $type = $request->get_param('type');
2129 if ($type) {
2130 $comment_type = $type;
2131 }
2132
2133 // Check the request body for type parameter (for POST requests)
2134 if (!$comment_type) {
2135 $body = $request->get_body_params();
2136 if (isset($body['type'])) {
2137 $comment_type = $body['type'];
2138 }
2139 }
2140
2141 // Check JSON body for type parameter
2142 if (!$comment_type) {
2143 $json = $request->get_json_params();
2144 if (isset($json['type'])) {
2145 $comment_type = $json['type'];
2146 }
2147 }
2148
2149 // For UPDATE requests (PUT/PATCH), check if the existing comment is an allowed type
2150 // WordPress doesn't send the type parameter when updating, only the ID and content
2151 if (!$comment_type) {
2152 $comment_id = $request->get_param('id');
2153 if ($comment_id) {
2154 $comment = get_comment($comment_id);
2155 if ($comment && isset($comment->comment_type)) {
2156 $comment_type = $comment->comment_type;
2157 }
2158 }
2159 }
2160
2161 // For DELETE requests, extract comment ID from the route path
2162 // The comment ID is only in the URL (e.g., /wp/v2/comments/123), not in request params
2163 if (!$comment_type && $request->is_method('DELETE')) {
2164 $route_parts = explode('/', $request->get_route());
2165 $comment_id = end($route_parts);
2166
2167 // Ensure we have a numeric comment ID
2168 if (is_numeric($comment_id)) {
2169 $comment = get_comment((int) $comment_id);
2170 if ($comment && isset($comment->comment_type)) {
2171 $comment_type = $comment->comment_type;
2172 }
2173 }
2174 }
2175 }
2176
2177 // Check if the comment type is in the allowlist
2178 if ($comment_type && $this->is_comment_type_allowed($comment_type)) {
2179 return true;
2180 }
2181
2182 return false;
2183 }
2184
2185 /**
2186 * Issue a 403 for all comment feed requests.
2187 */
2188 public function filter_query() {
2189 if (is_comment_feed()) {
2190 wp_die(esc_html__('Comments are closed.', 'disable-comments'), '', array('response' => 403));
2191 }
2192 }
2193
2194 /**
2195 * Remove comment links from the admin bar.
2196 */
2197 public function filter_admin_bar() {
2198 if (is_admin_bar_showing()) {
2199 // Remove comments links from admin bar.
2200 remove_action('admin_bar_menu', 'wp_admin_bar_comments_menu', 60);
2201 if (is_multisite()) {
2202 add_action('admin_bar_menu', array($this, 'remove_network_comment_links'), 500);
2203 }
2204 }
2205 }
2206
2207 /**
2208 * Remove the comments endpoint for the REST API
2209 * But allow WordPress 6.9+ block notes (type=note) to work
2210 */
2211 public function filter_rest_endpoints($endpoints) {
2212 // Don't remove endpoints entirely - instead we'll use permission callbacks
2213 // and other filters to block regular comments while allowing notes
2214
2215 // We still need to add a filter to block non-note requests
2216 // This is handled by rest_pre_dispatch filter added in init_filters
2217
2218 return $endpoints;
2219 }
2220
2221 /**
2222 * Filter REST API comment requests to block comments except allowed types
2223 *
2224 * @param mixed $result Response to replace the requested version with
2225 * @param WP_REST_Server $server Server instance
2226 * @param WP_REST_Request $request Request used to generate the response
2227 * @return mixed
2228 */
2229 /**
2230 * May a per-post rule reopen REST comment access?
2231 *
2232 * Only when rules are active AND the block is coming from the global
2233 * toggle or the post-type list. If the administrator explicitly ticked
2234 * "Disable comments via REST API", that is a statement about the API
2235 * rather than about any particular post, and no exception overrides it.
2236 *
2237 * @return bool
2238 */
2239 private function rest_blocking_is_conditional() {
2240 if (!$this->has_conditional_rules()) {
2241 return false;
2242 }
2243
2244 return empty($this->options['remove_rest_API_comments']);
2245 }
2246
2247 /**
2248 * Which post is a comment REST request about?
2249 *
2250 * Returns 0 when it cannot be determined - a listing request, say - so the
2251 * caller falls back to the site-wide decision rather than guessing.
2252 *
2253 * @param WP_REST_Request $request The request.
2254 * @param array|object $prepared_comment Prepared comment, when there is one.
2255 * @return int Post id, or 0.
2256 */
2257 private function get_request_post_id($request, $prepared_comment = null) {
2258 if (is_array($prepared_comment) && !empty($prepared_comment['comment_post_ID'])) {
2259 return (int) $prepared_comment['comment_post_ID'];
2260 }
2261
2262 if (is_object($prepared_comment) && !empty($prepared_comment->comment_post_ID)) {
2263 return (int) $prepared_comment->comment_post_ID;
2264 }
2265
2266 if (!is_object($request) || !method_exists($request, 'get_param')) {
2267 return 0;
2268 }
2269
2270 $post_id = $request->get_param('post');
2271
2272 if (is_scalar($post_id) && (int) $post_id > 0) {
2273 return (int) $post_id;
2274 }
2275
2276 // Item routes - GET/PUT/DELETE /wp/v2/comments/<id> - carry an id and
2277 // no post, so this used to answer 0 and the caller rejected them even
2278 // when an "enable" rule keeps that comment's post open. Reading or
2279 // editing a comment on a post the site has deliberately reopened has
2280 // to work, or the exception only half exists.
2281 //
2282 // This can only widen what a rule already permits: the caller still
2283 // asks is_disabled_for_post() about whatever post comes back.
2284 $comment_id = $request->get_param('id');
2285
2286 if (is_scalar($comment_id) && (int) $comment_id > 0) {
2287 $comment = get_comment((int) $comment_id);
2288
2289 if ($comment && !empty($comment->comment_post_ID)) {
2290 return (int) $comment->comment_post_ID;
2291 }
2292 }
2293
2294 return 0;
2295 }
2296
2297 /**
2298 * Did this REST request try to change something?
2299 *
2300 * Written as "not a read" rather than as a list of write verbs, so a
2301 * method nobody thought of counts rather than silently not counting. GET,
2302 * HEAD and OPTIONS are the safe methods HTTP defines; everything else -
2303 * POST, PUT, PATCH, DELETE - is an attempt to write. DELETE is included
2304 * deliberately: a refused attempt to delete a comment is still an attempt
2305 * the plugin turned away.
2306 *
2307 * Anything that is not a request object counts, so a caller that cannot be
2308 * inspected is treated the way this filter treated everything before.
2309 *
2310 * @since 2.9.1
2311 * @param WP_REST_Request|mixed $request The request being dispatched.
2312 * @return bool
2313 */
2314 private function is_rest_write_request($request) {
2315 if (!is_object($request) || !method_exists($request, 'get_method')) {
2316 return true;
2317 }
2318
2319 $method = strtoupper((string) $request->get_method());
2320
2321 return !in_array($method, array('GET', 'HEAD', 'OPTIONS'), true);
2322 }
2323
2324 public function filter_rest_comment_dispatch($result, $server, $request) {
2325 // Somebody upstream already rejected this - authentication, rate
2326 // limiting, a security plugin. WordPress keeps running later filters
2327 // anyway, so without this we would both replace their error with ours
2328 // and count their rejection as one of our blocks.
2329 if (is_wp_error($result)) {
2330 return $result;
2331 }
2332
2333 // Only filter comment-related routes
2334 $route = $request->get_route();
2335 if (strpos($route, '/wp/v2/comments') === false) {
2336 return $result;
2337 }
2338
2339 // Allow requests for comment types in the allowlist to pass through
2340 if ($this->is_allowed_comment_type_request($request)) {
2341 return $result;
2342 }
2343
2344 // Likewise for a post an exception rule keeps open - but again only
2345 // when the dedicated REST toggle is not what is doing the blocking.
2346 if ($this->rest_blocking_is_conditional()) {
2347 $post_id = $this->get_request_post_id($request);
2348 if ($post_id && !$this->is_disabled_for_post($post_id)) {
2349 return $result;
2350 }
2351 }
2352
2353 // Counted only when something tried to WRITE. The block below still
2354 // covers reads - that part is unchanged - but a blocked GET is not an
2355 // "attempt" in the sense the counter is read: an admin looks at that
2356 // number as spam pressure, and a crawler or uptime check polling
2357 // /wp/v2/comments would pad it with traffic that never tried to leave
2358 // a comment. It also handed an unauthenticated caller a way to force a
2359 // database write on every request, since a non-empty tally is flushed
2360 // on shutdown.
2361 if ($this->is_rest_write_request($request)) {
2362 $this->count_blocked_attempt('rest');
2363 }
2364
2365 // Block all other comment requests
2366 return new WP_Error(
2367 'rest_comment_disabled',
2368 __('Comments are disabled.', 'disable-comments'),
2369 array('status' => 403)
2370 );
2371 }
2372
2373 /**
2374 * Filter comment queries in REST API to allow only allowed comment types
2375 *
2376 * @param array $prepared_args Array of arguments for WP_Comment_Query
2377 * @param WP_REST_Request $request The REST API request
2378 * @return array
2379 */
2380 public function filter_rest_comment_query($prepared_args, $request) {
2381 // If this is a request for an allowed comment type, allow it
2382 if ($this->is_allowed_comment_type_request($request)) {
2383 return $prepared_args;
2384 }
2385
2386 // A post an exception keeps open must return its comments here too.
2387 // The pre-dispatch filter already lets the request through; forcing an
2388 // empty result set afterwards would hand back a 200 with nothing in it,
2389 // which is a subtler kind of wrong than a 403.
2390 if ($this->rest_blocking_is_conditional()) {
2391 $post_id = $this->get_request_post_id($request);
2392 if ($post_id && !$this->is_disabled_for_post($post_id)) {
2393 return $prepared_args;
2394 }
2395 }
2396
2397 // For non-allowed requests, return empty results
2398 // by setting an impossible condition
2399 $prepared_args['comment__in'] = array(0);
2400
2401 return $prepared_args;
2402 }
2403
2404 /**
2405 * Determines if scripts should be enqueued
2406 */
2407 public function filter_gutenberg_blocks($hook) {
2408 global $post;
2409 if ($this->is_remove_everywhere() || (isset($post->post_type) && $this->is_post_type_disabled($post->post_type))) {
2410 return $this->disable_comments_script();
2411 }
2412 }
2413
2414 /**
2415 * Enqueues scripts
2416 */
2417 public function disable_comments_script() {
2418 wp_enqueue_script('disable-comments-gutenberg', plugin_dir_url(__FILE__) . 'assets/js/disable-comments.js', array(), DC_VERSION, true);
2419 }
2420
2421 /**
2422 * Enqueues Scripts for Settings Page
2423 */
2424 public function settings_page_assets($hook_suffix) {
2425 // The review prompt is not a settings-page feature. review_prompt() is
2426 // hooked to admin_notices AND network_admin_notices, and renders on
2427 // every screen get_own_screen_ids() covers - the Tools page and the
2428 // -network variants included. Its dismiss handler used to ship only
2429 // inside the settings bundle below, so on those other screens nothing
2430 // listened: "No thanks", the click-through and the notice's own X all
2431 // appeared to work and the prompt returned on the next page load.
2432 //
2433 // Keyed off should_show_review_prompt() rather than the hook suffix, so
2434 // the script loads exactly where the notice does and nowhere else. It
2435 // runs before admin_notices and reads the same option and user meta, so
2436 // the two cannot disagree within a request.
2437 if ($this->should_show_review_prompt()) {
2438 wp_enqueue_script(
2439 'disable-comments-review-prompt',
2440 DC_ASSETS_URI . 'js/review-prompt.js',
2441 array('jquery'),
2442 DC_VERSION,
2443 true
2444 );
2445 }
2446
2447 if (
2448 $hook_suffix === 'settings_page_' . DC_PLUGIN_SLUG ||
2449 $hook_suffix === 'options-general_' . DC_PLUGIN_SLUG
2450 ) {
2451 // css
2452 wp_enqueue_style('sweetalert2', DC_ASSETS_URI . 'css/sweetalert2.min.css', [], DC_VERSION);
2453 // wp_enqueue_style('pagination', DC_ASSETS_URI . 'css/pagination.css', [], false);
2454 wp_enqueue_style('disable-comments-style', DC_ASSETS_URI . 'css/style.css', [], DC_VERSION);
2455 wp_enqueue_style('select2', DC_ASSETS_URI . 'css/select2.min.css', [], DC_VERSION);
2456 // js
2457 wp_enqueue_script('sweetalert2', DC_ASSETS_URI . 'js/sweetalert2.all.min.js', array('jquery'), DC_VERSION, true);
2458 wp_enqueue_script('pagination', DC_ASSETS_URI . 'js/pagination.min.js', array('jquery'), DC_VERSION, true);
2459 wp_enqueue_script('select2', DC_ASSETS_URI . 'js/select2.min.js', array('jquery'), DC_VERSION, true);
2460 wp_enqueue_script('disable-comments-scripts', DC_ASSETS_URI . 'js/disable-comments-settings-scripts.js', array('jquery', 'select2', 'pagination', 'sweetalert2', 'wp-i18n'), DC_VERSION, true);
2461 wp_localize_script(
2462 'disable-comments-scripts',
2463 'disableCommentsObj',
2464 array(
2465 'save_action' => 'disable_comments_save_settings',
2466 'delete_action' => 'disable_comments_delete_comments',
2467 'settings_URI' => $this->settings_page_url(),
2468 '_nonce' => wp_create_nonce('disable_comments_save_settings'),
2469 'is_network_admin' => is_network_admin() ? '1' : '0',
2470 )
2471 );
2472 wp_set_script_translations('disable-comments-scripts', 'disable-comments');
2473 } else {
2474 // notice css
2475 wp_enqueue_style('disable-comments-notice', DC_ASSETS_URI . 'css/notice.css', [], DC_VERSION);
2476 }
2477 }
2478
2479 /**
2480 * Remove comment links from the admin bar in a multisite network.
2481 */
2482 public function remove_network_comment_links($wp_admin_bar) {
2483 if ($this->networkactive && is_user_logged_in()) {
2484 foreach ((array) $wp_admin_bar->user->blogs as $blog) {
2485 $wp_admin_bar->remove_menu('blog-' . $blog->userblog_id . '-c');
2486 }
2487 } else {
2488 // We have no way to know whether the plugin is active on other sites, so only remove this one.
2489 $wp_admin_bar->remove_menu('blog-' . get_current_blog_id() . '-c');
2490 }
2491 }
2492
2493 public function discussion_notice() {
2494 $disabled_post_types = $this->get_disabled_post_types();
2495 if (get_current_screen()->id == 'options-discussion' && !empty($disabled_post_types)) {
2496 $names_escaped = array();
2497 foreach ($disabled_post_types as $type) {
2498 $names_escaped[$type] = esc_html(get_post_type_object($type)->labels->name);
2499 }
2500
2501 // translators: %s: disabled post types.
2502 echo '<div class="notice notice-warning"><p>' . sprintf(esc_html__('Note: The <em>Disable Comments</em> plugin is currently active, and comments are completely disabled on: %s. Many of the settings below will not be applicable for those post types.', 'disable-comments'), implode(esc_html__(', ', 'disable-comments'), $names_escaped)) . '</p></div>';
2503 }
2504 }
2505
2506 /**
2507 * Return context-aware settings page URL
2508 */
2509 private function settings_page_url() {
2510 $base = $this->networkactive && is_network_admin() ? network_admin_url('settings.php') : admin_url('options-general.php');
2511 return add_query_arg('page', DC_PLUGIN_SLUG, $base);
2512 }
2513
2514 /**
2515 * Return context-aware tools page URL
2516 */
2517 private function tools_page_url() {
2518 $base = $this->networkactive && is_network_admin() ? network_admin_url('settings.php') : admin_url('tools.php');
2519 return add_query_arg('page', 'disable_comments_tools', $base);
2520 }
2521
2522
2523 public function setup_notice() {
2524 $current_screen = get_current_screen()->id;
2525 if (!in_array($current_screen, ['dashboard-network', 'dashboard'])) {
2526 return;
2527 }
2528 $hascaps = $this->networkactive && is_network_admin() ? current_user_can('manage_network_plugins') : current_user_can('manage_options');
2529 if ($this->networkactive && !is_network_admin() && !$this->options['sitewide_settings']) {
2530 $hascaps = false;
2531 }
2532 if ($hascaps) {
2533 $this->setup_notice_flag = true;
2534 // translators: %s: URL to Disabled Comment settings page.
2535 $html = sprintf(__('The <strong>Disable Comments</strong> plugin is active, but isn\'t configured to do anything yet. Visit the <a href="%s">configuration page</a> to choose which post types to disable comments on.', 'disable-comments'), esc_attr($this->settings_page_url()));
2536 // phpcs:ignore PluginCheck.CodeAnalysis.ImageFunctions.NonEnqueuedImage
2537 echo wp_kses_post('<div class="notice dc-text__block disable__comment__alert mb30"><img height="30" src="' . esc_url(DC_ASSETS_URI . 'img/icon-logo.png') . '" alt=""><p>' . $html . '</p></div>');
2538 }
2539 }
2540
2541 public function filter_admin_menu() {
2542 global $pagenow;
2543
2544 if (empty($this->options['show_existing_comments'])) {
2545 if ($pagenow == 'comment.php' || $pagenow == 'edit-comments.php') {
2546 wp_die(esc_html__('Comments are closed.', 'disable-comments'), '', array('response' => 403));
2547 }
2548
2549 remove_menu_page('edit-comments.php');
2550 }
2551
2552 if (!$this->discussion_settings_allowed()) {
2553 if ($pagenow == 'options-discussion.php') {
2554 wp_die(esc_html__('Comments are closed.', 'disable-comments'), '', array('response' => 403));
2555 }
2556
2557 remove_submenu_page('options-general.php', 'options-discussion.php');
2558 }
2559 }
2560
2561 public function filter_dashboard() {
2562 remove_meta_box('dashboard_recent_comments', 'dashboard', 'normal');
2563 }
2564
2565 public function admin_css() {
2566 echo '<style>
2567 #dashboard_right_now .comment-count,
2568 #dashboard_right_now .comment-mod-count,
2569 #latest-comments,
2570 #welcome-panel .welcome-comments,
2571 .user-comment-shortcuts-wrap {
2572 display: none !important;
2573 }
2574 </style>';
2575 }
2576
2577 public function filter_existing_comments($comments, $post_id) {
2578 $comments_disabled = $this->is_disabled_for_post($post_id);
2579
2580 // If comments are disabled but show_existing_comments is enabled, return existing comments
2581 if ($comments_disabled && !empty($this->options['show_existing_comments'])) {
2582 $comments_disabled = false;
2583 }
2584
2585 // If comments are disabled, filter out regular comments but keep allowed comment types
2586 if ($comments_disabled && !empty($comments)) {
2587 $filtered_comments = array();
2588 foreach ($comments as $comment) {
2589 // Keep comment types that are in the allowlist even when comments are disabled
2590 if (isset($comment->comment_type) && $this->is_comment_type_allowed($comment->comment_type)) {
2591 $filtered_comments[] = $comment;
2592 }
2593 }
2594 return $filtered_comments;
2595 }
2596
2597 // Default behavior: return all comments if not disabled
2598 return $comments;
2599 }
2600
2601 public function filter_comment_status($open, $post_id) {
2602 return ($this->is_disabled_for_post($post_id) ? false : $open);
2603 }
2604
2605 public function filter_comments_number($count, $post_id) {
2606 $comments_disabled = $this->is_disabled_for_post($post_id);
2607
2608 // If comments are disabled but show_existing_comments is enabled, return actual count
2609 if ($comments_disabled && !empty($this->options['show_existing_comments'])) {
2610 return $count;
2611 }
2612
2613 // If comments are disabled but there are allowed comment types, count only those types
2614 if ($comments_disabled && $this->has_allowed_comment_types()) {
2615 return $this->count_allowed_comment_types($post_id);
2616 }
2617
2618 return $comments_disabled ? 0 : $count;
2619 }
2620
2621 /**
2622 * Count comments of allowed types for a specific post
2623 *
2624 * @param int $post_id The post ID
2625 * @return int The count of comments matching allowed types
2626 */
2627 private function count_allowed_comment_types($post_id) {
2628 $allowed_types = $this->get_allowed_comment_types();
2629 if (empty($allowed_types)) {
2630 return 0;
2631 }
2632
2633 $comments = get_comments(array(
2634 'post_id' => $post_id,
2635 'type__in' => $allowed_types,
2636 'status' => 'approve',
2637 'count' => true,
2638 ));
2639
2640 return (int) $comments;
2641 }
2642
2643 public function disable_rc_widget() {
2644 unregister_widget('WP_Widget_Recent_Comments');
2645 /**
2646 * The widget has added a style action when it was constructed - which will
2647 * still fire even if we now unregister the widget... so filter that out
2648 */
2649 add_filter('show_recent_comments_widget_style', '__return_false');
2650 }
2651
2652 public function set_plugin_meta($links, $file) {
2653 static $plugin;
2654 $plugin = plugin_basename(__FILE__);
2655 if ($file == $plugin) {
2656 $links[] = '<a href="https://github.com/WPDevelopers/disable-comments">GitHub</a>';
2657 }
2658 return $links;
2659 }
2660
2661 /**
2662 * Add links to Settings page
2663 */
2664 public function plugin_actions_links($links, $file) {
2665 static $plugin;
2666 $plugin = plugin_basename(__FILE__);
2667 if ($file == $plugin && current_user_can('manage_options')) {
2668 array_unshift(
2669 $links,
2670 sprintf('<a href="%s">%s</a>', esc_attr($this->settings_page_url()), __('Settings', 'disable-comments')),
2671 sprintf('<a href="%s">%s</a>', esc_attr($this->tools_page_url()), __('Tools', 'disable-comments'))
2672 );
2673 }
2674
2675 return $links;
2676 }
2677
2678 public function settings_menu() {
2679 $title = _x('Disable Comments', 'settings menu title', 'disable-comments');
2680 if ($this->networkactive && is_network_admin()) {
2681 add_submenu_page('settings.php', $title, $title, 'manage_network_plugins', DC_PLUGIN_SLUG, array($this, 'settings_page'));
2682 } elseif (!$this->networkactive || $this->options['sitewide_settings']) {
2683 add_submenu_page('options-general.php', $title, $title, 'manage_options', DC_PLUGIN_SLUG, array($this, 'settings_page'));
2684 }
2685 }
2686
2687 public function tools_menu() {
2688 $title = __('Delete Comments', 'disable-comments');
2689 $hook = '';
2690 if ($this->networkactive && is_network_admin()) {
2691 $hook = add_submenu_page('settings.php', $title, $title, 'manage_network_plugins', 'disable_comments_tools', array($this, 'tools_page'));
2692 } elseif (!$this->networkactive || $this->options['sitewide_settings']) {
2693 $hook = add_submenu_page('tools.php', $title, $title, 'manage_options', 'disable_comments_tools', array($this, 'tools_page'));
2694 }
2695 add_action('load-' . $hook, array($this, 'redirectToMainSettingsPage'));
2696 }
2697
2698 public function redirectToMainSettingsPage() {
2699 wp_safe_redirect($this->settings_page_url() . '#delete');
2700 exit;
2701 }
2702
2703 public function get_all_comments_number() {
2704 global $wpdb;
2705 if (is_network_admin() && function_exists('get_sites') && class_exists('WP_Site_Query')) {
2706 $count = 0;
2707 $sites = get_sites([
2708 'number' => 0,
2709 'fields' => 'ids',
2710 ]);
2711 foreach ($sites as $blog_id) {
2712 switch_to_blog($blog_id);
2713 $count += $this->__get_comment_count();
2714 restore_current_blog();
2715 }
2716 return $count;
2717 } else {
2718 return $this->__get_comment_count();
2719 }
2720 }
2721
2722 public function get_all_comment_types($exclude_allowed = true) {
2723 if ($this->networkactive && is_network_admin() && function_exists('get_sites')) {
2724 $comment_types = [];
2725 $sites = get_sites([
2726 'number' => 0,
2727 'fields' => 'ids',
2728 ]);
2729 foreach ($sites as $blog_id) {
2730 switch_to_blog($blog_id);
2731 $comment_types = array_merge($this->_get_all_comment_types($exclude_allowed), $comment_types);
2732 restore_current_blog();
2733 }
2734 return $comment_types;
2735 } else {
2736 return $this->_get_all_comment_types($exclude_allowed);
2737 }
2738 }
2739 public function _get_all_comment_types($exclude_allowed = true) {
2740 global $wpdb;
2741 $commenttypes = array();
2742 // we need fresh data in every call.
2743 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery -- We need to count comments across multiple sites
2744 $commenttypes_query = $wpdb->get_results("SELECT DISTINCT comment_type FROM $wpdb->comments", ARRAY_A);
2745 if (!empty($commenttypes_query) && is_array($commenttypes_query)) {
2746 foreach ($commenttypes_query as $entry) {
2747 $value = $entry['comment_type'];
2748 // Exclude comment types that are in the allowlist from deletable comment types
2749 // These are protected and should not appear in the "Delete Certain Comment Types" interface
2750 if ($exclude_allowed && $this->is_comment_type_allowed($value)) {
2751 continue;
2752 }
2753 if ('' === $value) {
2754 $commenttypes['default'] = __('Default (no type)', 'disable-comments');
2755 } elseif ($this->is_woocommerce_active() && $this->get_review_comment_type() === $value) {
2756 // "Review (review)" tells a store owner nothing about what
2757 // they are about to delete.
2758 $commenttypes[$value] = __('Product reviews - WooCommerce (review)', 'disable-comments');
2759 } else {
2760 $commenttypes[$value] = ucwords(str_replace('_', ' ', $value)) . ' (' . $value . ')';
2761 }
2762 }
2763 }
2764 return $commenttypes;
2765 }
2766
2767 public function get_all_post_types($network = false) {
2768 $typeargs = array('public' => true);
2769 if ($network || $this->networkactive && is_network_admin()) {
2770 $typeargs['_builtin'] = true; // stick to known types for network.
2771 }
2772 $types = get_post_types($typeargs, 'objects');
2773 foreach (array_keys($types) as $type) {
2774 if (!in_array($type, $this->modified_types) && !post_type_supports($type, 'comments')) { // the type doesn't support comments anyway.
2775 unset($types[$type]);
2776 }
2777 }
2778 return $types;
2779 }
2780
2781 public function get_roles($selected) {
2782 $roles = [
2783 [
2784 "id" => 'logged-out-users',
2785 "text" => __('Logged out users', 'disable-comments'),
2786 "selected" => in_array('logged-out-users', (array) $selected),
2787 ]
2788 ];
2789 $editable_roles = array_reverse(get_editable_roles());
2790 foreach ($editable_roles as $role => $details) {
2791 $roles[] = [
2792 "id" => esc_attr($role),
2793 "text" => esc_html(translate_user_role($details['name'])),
2794 "selected" => in_array($role, (array) $selected),
2795 ];
2796 }
2797 return $roles;
2798 }
2799
2800 public function tools_page() {
2801 return;
2802 }
2803
2804 public function settings_page() {
2805 // Belt-and-suspenders: add_submenu_page already gates on capability,
2806 // but verify here too so a direct URL request can never render the page.
2807 $required_cap = $this->networkactive && is_network_admin() ? 'manage_network_plugins' : 'manage_options';
2808 if (!current_user_can($required_cap)) {
2809 wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'disable-comments'), 403);
2810 }
2811
2812 $avatar_status = '-1';
2813 if ($this->can_network_admin_ajax_context()) {
2814 $show_avatars = [];
2815 $sites = get_sites([
2816 'number' => 0,
2817 'fields' => 'ids',
2818 ]);
2819 foreach ($sites as $blog_id) {
2820 switch_to_blog($blog_id);
2821 $show_avatars[] = (int) get_option('show_avatars', '0');
2822 restore_current_blog();
2823 }
2824 if (count($show_avatars) == array_sum($show_avatars)) {
2825 $avatar_status = '0';
2826 } elseif (0 == array_sum($show_avatars)) {
2827 $avatar_status = '1';
2828 }
2829 }
2830
2831 include_once DC_PLUGIN_VIEWS_PATH . 'settings.php';
2832 }
2833
2834 public function get_sub_sites() {
2835 $nonce = (isset($_REQUEST['nonce']) ? sanitize_text_field(wp_unslash($_REQUEST['nonce'])) : '');
2836 if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
2837 wp_send_json(['data' => [], 'totalNumber' => 0]);
2838 }
2839 // Listing subsites is always a network-level operation on multisite —
2840 // require manage_network_plugins regardless of how the plugin is activated
2841 // (network-wide or per-site). A per-site admin must never enumerate all
2842 // network sites. On single-site installs manage_options suffices.
2843 $required_cap = is_multisite() ? 'manage_network_plugins' : 'manage_options';
2844 if (!current_user_can($required_cap)) {
2845 wp_send_json(['data' => [], 'totalNumber' => 0]);
2846 }
2847
2848 $_sub_sites = [];
2849 $type = isset($_GET['type']) ? sanitize_text_field(wp_unslash($_GET['type'])) : 'disabled';
2850 $search = isset($_GET['search']) ? sanitize_text_field(wp_unslash($_GET['search'])) : '';
2851 $pageSize = isset($_GET['pageSize']) ? sanitize_text_field(wp_unslash($_GET['pageSize'])) : 50;
2852 $pageNumber = isset($_GET['pageNumber']) ? sanitize_text_field(wp_unslash($_GET['pageNumber'])) : 1;
2853 $offset = ($pageNumber - 1) * $pageSize;
2854 $sub_sites = get_sites([
2855 'number' => $pageSize,
2856 'offset' => $offset,
2857 'search' => $search,
2858 'fields' => 'ids',
2859 ]);
2860 $totalNumber = get_sites([
2861 // 'number' => $pageSize,
2862 // 'offset' => $offset,
2863 'search' => $search,
2864 'count' => true,
2865 ]);
2866
2867 if ($type == 'disabled') {
2868 $disabled_site_options = isset($this->options['disabled_sites']) ? $this->options['disabled_sites'] : [];
2869 } else { // if($type == 'delete')
2870 $disabled_site_options = $this->get_disabled_sites(true);
2871 }
2872
2873 foreach ($sub_sites as $sub_site_id) {
2874 $blog = get_blog_details($sub_site_id);
2875 $is_checked = checked(!empty($disabled_site_options["site_$sub_site_id"]), true, false);
2876 $_sub_sites[] = [
2877 'site_id' => $sub_site_id,
2878 'is_checked' => $is_checked,
2879 'blogname' => $blog->blogname,
2880 ];
2881 }
2882 wp_send_json(['data' => $_sub_sites, 'totalNumber' => $totalNumber]);
2883 }
2884
2885 /**
2886 * AJAX: terms for one taxonomy, for the conditional-rules term picker.
2887 *
2888 * Reads nothing a user with manage_options cannot already see on the
2889 * taxonomy screens, but it is gated all the same - an endpoint that
2890 * enumerates every term on the site is not something to leave open.
2891 */
2892 public function get_taxonomy_terms() {
2893 $nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');
2894
2895 if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
2896 wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
2897 }
2898
2899 if (!current_user_can('manage_options')) {
2900 wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
2901 }
2902
2903 $taxonomy = (isset($_POST['taxonomy']) ? sanitize_key(wp_unslash($_POST['taxonomy'])) : '');
2904 // sanitize_text_field(), not sanitize_key(): this is matched against
2905 // term names, which have spaces, accents and capitals in them.
2906 $search = (isset($_POST['search']) ? sanitize_text_field(wp_unslash($_POST['search'])) : '');
2907 $page = (isset($_POST['page']) ? (int) $_POST['page'] : 1);
2908
2909 wp_send_json_success($this->get_taxonomy_terms_page($taxonomy, $search, $page));
2910 }
2911
2912 /**
2913 * One page of terms for the conditional-rules picker.
2914 *
2915 * The picker used to be handed the taxonomy's first 200 terms and nothing
2916 * else. On a shop with a few thousand product categories that meant every
2917 * term after those 200 was simply unreachable for a new rule - visible on
2918 * the taxonomy screen, listed by the REST API, and absent from the one
2919 * control that needed it. Raising the number would have moved the ceiling
2920 * rather than removed it, so the picker searches and pages instead.
2921 *
2922 * Kept apart from the AJAX wrapper above so the query can be exercised
2923 * directly; the wrapper owns the nonce and capability checks.
2924 *
2925 * @param string $taxonomy Taxonomy name.
2926 * @param string $search Optional. Fragment to match against term names.
2927 * @param int $page Optional. 1-based page of results.
2928 * @return array {
2929 * @type string $taxonomy The taxonomy answered for. Echoed back because
2930 * two quick changes of taxonomy can land out of
2931 * order, and terms rendered under the wrong one
2932 * save as term IDs that do not belong to it.
2933 * @type array $terms List of array('id' => int, 'name' => string).
2934 * @type bool $more Whether a further page exists.
2935 * }
2936 */
2937 public function get_taxonomy_terms_page($taxonomy, $search = '', $page = 1) {
2938 $empty = array(
2939 'taxonomy' => (string) $taxonomy,
2940 'terms' => array(),
2941 'more' => false,
2942 );
2943
2944 if ('' === $taxonomy || !taxonomy_exists($taxonomy)) {
2945 return $empty;
2946 }
2947
2948 $page = max(1, (int) $page);
2949
2950 $args = array(
2951 'taxonomy' => $taxonomy,
2952 'hide_empty' => false,
2953 'orderby' => 'name',
2954 'order' => 'ASC',
2955 // id=>name, because nothing here needs a WP_Term: on a taxonomy
2956 // with thousands of rows the objects are the expensive part.
2957 'fields' => 'id=>name',
2958 // One row past the page. Whether another page exists is then
2959 // answered by this query rather than a second counting one.
2960 'number' => self::TERM_PAGE_SIZE + 1,
2961 'offset' => ($page - 1) * self::TERM_PAGE_SIZE,
2962 );
2963
2964 $search = trim((string) $search);
2965 if ('' !== $search) {
2966 $args['search'] = $search;
2967 }
2968
2969 $terms = get_terms($args);
2970
2971 if (is_wp_error($terms) || empty($terms)) {
2972 return $empty;
2973 }
2974
2975 $more = (count($terms) > self::TERM_PAGE_SIZE);
2976 if ($more) {
2977 array_pop($terms);
2978 }
2979
2980 $out = array();
2981 foreach ($terms as $term_id => $name) {
2982 $out[] = array(
2983 'id' => (int) $term_id,
2984 'name' => $name,
2985 );
2986 }
2987
2988 return array(
2989 'taxonomy' => (string) $taxonomy,
2990 'terms' => $out,
2991 'more' => $more,
2992 );
2993 }
2994
2995 /**
2996 * Screens this plugin owns.
2997 *
2998 * The review prompt renders on these and nowhere else. Two 1-star reviews
2999 * in May 2025 were about promotion appearing in the post editor and around
3000 * the admin; both were revised to 5 stars once it was pulled. This list is
3001 * the guarantee that does not happen again, so keep it exact.
3002 *
3003 * @return array Screen ids.
3004 */
3005 private function get_own_screen_ids() {
3006 $ids = array(
3007 'settings_page_' . DC_PLUGIN_SLUG,
3008 'options-general_page_' . DC_PLUGIN_SLUG,
3009 'tools_page_disable_comments_tools',
3010 'settings_page_disable_comments_tools',
3011 );
3012
3013 // On a network-activated install WordPress suffixes screen ids with
3014 // "-network". Without these a super admin who just cleared comments
3015 // across the whole network is the one person never asked.
3016 foreach ($ids as $id) {
3017 $ids[] = $id . '-network';
3018 }
3019
3020 return $ids;
3021 }
3022
3023 /**
3024 * Are we on one of this plugin's own screens?
3025 *
3026 * @return bool
3027 */
3028 private function is_own_screen() {
3029 if (!function_exists('get_current_screen')) {
3030 return false;
3031 }
3032
3033 $screen = get_current_screen();
3034
3035 if (!$screen || empty($screen->id)) {
3036 return false;
3037 }
3038
3039 return in_array($screen->id, $this->get_own_screen_ids(), true);
3040 }
3041
3042 /**
3043 * Note that something worth being pleased about just happened.
3044 *
3045 * Called after a bulk delete completes. Activation deliberately does not
3046 * call this: the user has done nothing yet and has no basis for an opinion.
3047 *
3048 * @param int $deleted How many comments were removed.
3049 */
3050 public function record_review_trigger($deleted = 0) {
3051 if ($deleted < 1) {
3052 return;
3053 }
3054
3055 update_option(
3056 self::REVIEW_TRIGGER_OPTION,
3057 array(
3058 'at' => time(),
3059 'deleted' => (int) $deleted,
3060 ),
3061 false
3062 );
3063 }
3064
3065 /**
3066 * Should this user see the review prompt right now?
3067 *
3068 * Every condition here is a reason not to show it. That asymmetry is the
3069 * point: the cost of showing this in the wrong place is far higher than
3070 * the cost of never showing it at all.
3071 *
3072 * @return bool
3073 */
3074 public function should_show_review_prompt() {
3075 /**
3076 * Filter whether the review prompt may be shown at all.
3077 *
3078 * @param bool $show Whether to consider showing the prompt.
3079 */
3080 if (!apply_filters('disable_comments_show_review_prompt', true)) {
3081 return false;
3082 }
3083
3084 if (!is_user_logged_in() || !current_user_can('manage_options')) {
3085 return false;
3086 }
3087
3088 // Our screens only. Never the dashboard, never the post editor, never
3089 // a site-wide admin notice.
3090 if (!$this->is_own_screen()) {
3091 return false;
3092 }
3093
3094 // Dismissed means dismissed. Permanently, for this user.
3095 if (get_user_meta(get_current_user_id(), self::REVIEW_DISMISSED_META, true)) {
3096 return false;
3097 }
3098
3099 $trigger = get_option(self::REVIEW_TRIGGER_OPTION, array());
3100
3101 // No successful action yet, so there is nothing to be pleased about.
3102 if (empty($trigger['at']) || empty($trigger['deleted'])) {
3103 return false;
3104 }
3105
3106 return true;
3107 }
3108
3109 /**
3110 * Render the review prompt.
3111 */
3112 public function review_prompt() {
3113 if (!$this->should_show_review_prompt()) {
3114 return;
3115 }
3116
3117 $trigger = get_option(self::REVIEW_TRIGGER_OPTION, array());
3118 $deleted = isset($trigger['deleted']) ? (int) $trigger['deleted'] : 0;
3119
3120 ?>
3121 <div class="notice notice-info is-dismissible disable-comments-review-prompt"
3122 id="disable_comments_review_prompt"
3123 data-nonce="<?php echo esc_attr(wp_create_nonce('disable_comments_save_settings')); ?>">
3124 <p>
3125 <?php
3126 printf(
3127 /* translators: %s: number of comments deleted. */
3128 esc_html(_n('Disable Comments just cleared %s comment for you.', 'Disable Comments just cleared %s comments for you.', $deleted, 'disable-comments')),
3129 '<strong>' . esc_html(number_format_i18n($deleted)) . '</strong>'
3130 );
3131 ?>
3132 <?php esc_html_e('If it saved you some time, a quick review helps other people find it.', 'disable-comments'); ?>
3133 </p>
3134 <p>
3135 <a href="https://wordpress.org/support/plugin/disable-comments/reviews/#new-post"
3136 class="button button-primary"
3137 target="_blank"
3138 rel="noopener noreferrer"
3139 data-dc-review="leave">
3140 <?php esc_html_e('Leave a review', 'disable-comments'); ?>
3141 </a>
3142 <button type="button" class="button-link" data-dc-review="dismiss">
3143 <?php esc_html_e('No thanks, don\'t ask again', 'disable-comments'); ?>
3144 </button>
3145 </p>
3146 </div>
3147 <?php
3148 }
3149
3150 /**
3151 * AJAX: record that this user does not want to be asked again.
3152 */
3153 public function dismiss_review_prompt() {
3154 $nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');
3155
3156 if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
3157 wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
3158 }
3159
3160 if (!is_user_logged_in()) {
3161 wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
3162 }
3163
3164 update_user_meta(get_current_user_id(), self::REVIEW_DISMISSED_META, time());
3165
3166 wp_send_json_success();
3167 }
3168
3169 /**
3170 * Hook the self-report into a scanned front-end request.
3171 *
3172 * Called from init_filters(). Everything here is inert unless the request
3173 * carries a token this site issued moments ago, so an ordinary visitor
3174 * never touches any of it.
3175 */
3176 public function maybe_arm_scan_probe() {
3177 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
3178 $token = isset($_GET[self::SCAN_QUERY_ARG]) ? sanitize_text_field(wp_unslash($_GET[self::SCAN_QUERY_ARG])) : '';
3179
3180 if ('' === $token) {
3181 return;
3182 }
3183
3184 // Keyed by the token itself, so two administrators scanning at once do
3185 // not overwrite each other's slot and then both fail validation.
3186 $expected = get_transient(self::SCAN_TOKEN_TRANSIENT . '_' . md5($token));
3187
3188 // hash_equals: the token is a secret for the lifetime of one scan, and
3189 // a timing oracle on it is free to avoid.
3190 if (empty($expected) || !hash_equals((string) $expected, $token)) {
3191 return;
3192 }
3193
3194 // Record what the theme asked for. Our own filter runs at priority 20;
3195 // this sits later so it sees the final resolved path either way.
3196 add_filter('comments_template', array($this, 'record_scanned_comments_template'), 999);
3197 add_action('wp_footer', array($this, 'print_scan_marker'), 9999);
3198 add_action('shutdown', array($this, 'print_scan_marker'), 9999);
3199 }
3200
3201 /**
3202 * Remember which comments template was resolved during a scan.
3203 *
3204 * @param string $template Resolved template path.
3205 * @return string Unmodified.
3206 */
3207 public function record_scanned_comments_template($template) {
3208 $this->scan_report['comments_template'] = (string) $template;
3209 $this->scan_report['template_called'] = true;
3210
3211 return $template;
3212 }
3213
3214 /**
3215 * Emit the scan's findings as an HTML comment.
3216 *
3217 * Printed from inside the scanned request, which is the only place that
3218 * can say whether the theme called comments_template() at all - the thing
3219 * the FAQ has been describing in prose for a decade.
3220 */
3221 public function print_scan_marker() {
3222 if (!empty($this->scan_report['printed'])) {
3223 return;
3224 }
3225
3226 $this->scan_report['printed'] = true;
3227
3228 $report = array(
3229 'template_called' => !empty($this->scan_report['template_called']),
3230 'comments_template' => isset($this->scan_report['comments_template']) ? $this->scan_report['comments_template'] : '',
3231 'dummy_used' => isset($this->scan_report['comments_template'])
3232 && $this->scan_report['comments_template'] === $this->dummy_comments_template(),
3233 'comments_open' => is_singular() ? (bool) comments_open(get_queried_object_id()) : null,
3234 // These two make check_comment_template() deliberately leave the
3235 // real template in place, so comment markup on the page is expected
3236 // rather than a theme defeating us.
3237 'preserved' => !empty($this->options['show_existing_comments']) || $this->has_allowed_comment_types(),
3238 );
3239
3240 echo "\n<!--disable-comments-scan:" . wp_json_encode($report) . ":disable-comments-scan-->\n";
3241 }
3242
3243 /**
3244 * Markup that means a comment UI reached the page.
3245 *
3246 * Matched against class and id attributes rather than visible text, so it
3247 * does not depend on the site's language.
3248 *
3249 * @return array Signal key => list of needles.
3250 */
3251 private function get_comment_markup_signals() {
3252 return array(
3253 // Anchored to a tag boundary and an attribute name. A bare
3254 // substring search matches ".comment-form" in inline CSS, a
3255 // selector in inline JS, or the word inside a text node, and would
3256 // report a co-operating theme as broken.
3257 'comment_form' => array(
3258 '/<form[^>]+id=["\']commentform["\']/i',
3259 '/<form[^>]+class=["\'][^"\']*\bcomment-form\b/i',
3260 '/<div[^>]+id=["\']respond["\']/i',
3261 ),
3262 'comment_list' => array(
3263 '/<(ol|ul)[^>]+class=["\'][^"\']*\bcomment-list\b/i',
3264 '/<(div|section)[^>]+id=["\']comments["\']/i',
3265 ),
3266 'reply_script' => array(
3267 '/<script[^>]+comment-reply(\.min)?\.js/i',
3268 ),
3269 );
3270 }
3271
3272 /**
3273 * Strip the parts of a response that are not rendered markup.
3274 *
3275 * Inline styles and scripts routinely mention .comment-form and
3276 * .comment-list. Matching them would make every theme that styles its
3277 * comment area look like it was rendering one.
3278 *
3279 * @param string $body Response body.
3280 * @return string Body with script and style blocks removed.
3281 */
3282 private function strip_non_markup($body) {
3283 $body = preg_replace('#<script\b[^>]*>.*?</script>#is', '', $body);
3284 $body = preg_replace('#<style\b[^>]*>.*?</style>#is', '', $body);
3285 $body = preg_replace('#<!--(?!\s*disable-comments-scan).*?-->#s', '', $body);
3286
3287 return (string) $body;
3288 }
3289
3290 /**
3291 * Scan one URL and work out whether the theme is co-operating.
3292 *
3293 * @param string $url Permalink to scan.
3294 * @return array|WP_Error Findings, or an error when the page could not be fetched.
3295 */
3296 public function run_theme_scan($url) {
3297 $token = wp_generate_password(20, false);
3298 $slot = self::SCAN_TOKEN_TRANSIENT . '_' . md5($token);
3299 set_transient($slot, $token, 2 * MINUTE_IN_SECONDS);
3300
3301 // A page cache serves identical output no matter what changed, so an
3302 // un-busted request proves nothing. See CLAUDE.md - a full front-end
3303 // pass once returned identical results across four different states
3304 // purely as a cache artefact.
3305 $request_url = add_query_arg(
3306 array(
3307 self::SCAN_QUERY_ARG => $token,
3308 'dc_cb' => time(),
3309 ),
3310 $url
3311 );
3312
3313 $response = wp_remote_get(
3314 $request_url,
3315 array(
3316 'timeout' => 15,
3317 'redirection' => 3,
3318 'sslverify' => false,
3319 'headers' => array('Cache-Control' => 'no-cache'),
3320 )
3321 );
3322
3323 delete_transient($slot);
3324
3325 if (is_wp_error($response)) {
3326 // Loopback requests are blocked on plenty of hosts. That is a
3327 // "could not check", never a pass and never a fatal.
3328 return new WP_Error(
3329 'dc_scan_unreachable',
3330 sprintf(
3331 /* translators: %s: error message from the HTTP request. */
3332 __('Could not load the front end to check it (%s). Some hosts block a site from requesting its own pages; that is not a problem with your theme.', 'disable-comments'),
3333 $response->get_error_message()
3334 )
3335 );
3336 }
3337
3338 $code = (int) wp_remote_retrieve_response_code($response);
3339
3340 if ($code < 200 || $code >= 300) {
3341 return new WP_Error(
3342 'dc_scan_http_error',
3343 sprintf(
3344 /* translators: %d: HTTP status code. */
3345 __('The front end returned HTTP %d, so there was nothing to check.', 'disable-comments'),
3346 $code
3347 )
3348 );
3349 }
3350
3351 $body = (string) wp_remote_retrieve_body($response);
3352
3353 $report = array();
3354 if (preg_match('/<!--disable-comments-scan:(.*?):disable-comments-scan-->/s', $body, $matches)) {
3355 $decoded = json_decode($matches[1], true);
3356 if (is_array($decoded)) {
3357 $report = $decoded;
3358 }
3359 }
3360
3361 $markup = $this->strip_non_markup($body);
3362
3363 $found = array();
3364 foreach ($this->get_comment_markup_signals() as $signal => $patterns) {
3365 foreach ($patterns as $pattern) {
3366 if (preg_match($pattern, $markup)) {
3367 $found[] = $signal;
3368 break;
3369 }
3370 }
3371 }
3372
3373 $theme = wp_get_theme();
3374
3375 return array(
3376 'url' => $url,
3377 'reached' => !empty($report),
3378 'template_called' => !empty($report['template_called']),
3379 'comments_template' => isset($report['comments_template']) ? $report['comments_template'] : '',
3380 'dummy_used' => !empty($report['dummy_used']),
3381 'preserved' => !empty($report['preserved']),
3382 'comments_open' => isset($report['comments_open']) ? $report['comments_open'] : null,
3383 'markup_found' => $found,
3384 'theme' => $theme ? $theme->get('Name') : '',
3385 'theme_comments_php' => $this->locate_theme_comments_template(),
3386 // A cached response is why "nothing I change makes any difference".
3387 // Surface it rather than letting it silently invalidate the result.
3388 'x_cache' => wp_remote_retrieve_header($response, 'x-cache'),
3389 'http_code' => $code,
3390 );
3391 }
3392
3393 /**
3394 * The active theme's own comments.php, if it has one.
3395 *
3396 * locate_template() falls back to core's theme-compat copy when the theme
3397 * has none, and telling somebody to edit a file in wp-includes is worse
3398 * advice than telling them nothing.
3399 *
3400 * @return string Absolute path, or '' when the theme provides none.
3401 */
3402 private function locate_theme_comments_template() {
3403 $found = locate_template('comments.php');
3404
3405 if (empty($found)) {
3406 return '';
3407 }
3408
3409 foreach (array(get_stylesheet_directory(), get_template_directory()) as $dir) {
3410 if (0 === strpos($found, $dir)) {
3411 return $found;
3412 }
3413 }
3414
3415 return '';
3416 }
3417
3418 /**
3419 * Turn raw findings into a verdict and something worth reading.
3420 *
3421 * @param array $scan Result of run_theme_scan().
3422 * @return array Scan plus `verdict` and `message`.
3423 */
3424 public function interpret_theme_scan($scan) {
3425 $has_markup = !empty($scan['markup_found']);
3426
3427 if (!$scan['reached']) {
3428 // Without the marker we cannot tell a co-operating theme from a
3429 // cached page, so we say so instead of guessing.
3430 $scan['verdict'] = 'inconclusive';
3431 $scan['message'] = __('The page loaded, but this plugin could not report from inside it. That usually means a page cache served an older copy. Try again, or clear your cache first.', 'disable-comments');
3432
3433 return $scan;
3434 }
3435
3436 if (false === $scan['comments_open'] && !$has_markup) {
3437 $scan['verdict'] = 'clean';
3438 $scan['message'] = __('Comments are off on this page and your theme is respecting that. Nothing to fix.', 'disable-comments');
3439
3440 return $scan;
3441 }
3442
3443 // "Show existing comments" and the comment-type allowlist both tell
3444 // this plugin to leave the real template alone, so comment markup here
3445 // is the setting working - not the theme defeating it. Saying otherwise
3446 // would send someone editing their theme to fix something they asked
3447 // for.
3448 if (!empty($scan['preserved'])) {
3449 $scan['verdict'] = 'preserved_by_setting';
3450 $scan['message'] = __('Comments are off on this page, but you have chosen to keep existing comments (or certain comment types) visible — so the comment area is still rendered on purpose. If you did not expect that, check "Show Existing Comments" and "Enable Certain Comment Types" in the settings.', 'disable-comments');
3451
3452 return $scan;
3453 }
3454
3455 if (true === $scan['comments_open']) {
3456 $scan['verdict'] = 'not_disabled';
3457 $scan['message'] = __('Comments are still open on this page, so there is nothing for the theme to hide yet. Check the settings above before reading anything into this.', 'disable-comments');
3458
3459 return $scan;
3460 }
3461
3462 // Comments are closed and the theme rendered a comment UI anyway.
3463 if (!$scan['template_called']) {
3464 $scan['verdict'] = 'theme_ignores_template';
3465 $scan['message'] = sprintf(
3466 /* translators: 1: theme name, 2: template file path. */
3467 __('%1$s never calls comments_template(), so this plugin has no way to replace what it renders. The comment markup is being output directly by the theme. Look in %2$s, or in the single-post template that draws the comment area.', 'disable-comments'),
3468 $scan['theme'],
3469 $scan['theme_comments_php'] ? $scan['theme_comments_php'] : 'the theme\'s template files'
3470 );
3471
3472 return $scan;
3473 }
3474
3475 if (!$scan['dummy_used']) {
3476 $scan['verdict'] = 'template_overridden';
3477 $scan['message'] = sprintf(
3478 /* translators: 1: theme name, 2: resolved template path. */
3479 __('%1$s calls comments_template(), but something is overriding the empty template this plugin substitutes. The template actually rendered was %2$s.', 'disable-comments'),
3480 $scan['theme'],
3481 $scan['comments_template']
3482 );
3483
3484 return $scan;
3485 }
3486
3487 $scan['verdict'] = 'markup_outside_template';
3488 $scan['message'] = sprintf(
3489 /* translators: %s: theme name. */
3490 __('This plugin replaced the comments template successfully, but comment markup is still on the page — so %s is drawing part of the comment area outside comments_template(). That part has to be removed in the theme.', 'disable-comments'),
3491 $scan['theme']
3492 );
3493
3494 return $scan;
3495 }
3496
3497 /**
3498 * How much a verdict matters, for picking which page to report.
3499 *
3500 * @param string $verdict Verdict key.
3501 * @return int Higher is more serious.
3502 */
3503 private function scan_verdict_rank($verdict) {
3504 $order = array(
3505 'clean' => 0,
3506 'preserved_by_setting' => 1,
3507 'not_disabled' => 2,
3508 'inconclusive' => 3,
3509 'markup_outside_template' => 4,
3510 'template_overridden' => 5,
3511 'theme_ignores_template' => 6,
3512 );
3513
3514 return isset($order[$verdict]) ? $order[$verdict] : 0;
3515 }
3516
3517 /**
3518 * A published post the plugin currently closes comments on.
3519 *
3520 * Scanning a page where comments are open would prove nothing, so the
3521 * scanner picks a target rather than trusting whatever it is handed.
3522 *
3523 * @return int|false Post id, or false when there is nothing suitable.
3524 */
3525 public function find_scan_target() {
3526 $targets = $this->find_scan_targets();
3527
3528 return empty($targets) ? false : $targets[0];
3529 }
3530
3531 /**
3532 * One published post per disabled post type.
3533 *
3534 * Scanning a single arbitrary post is how a scanner reports "clean" while
3535 * the page the user is actually complaining about is broken: themes
3536 * commonly hand different post types to different templates, and only one
3537 * of them may be drawing its own comment area. One representative per
3538 * disabled type is the smallest set that cannot miss that.
3539 *
3540 * @param int $limit Maximum number of posts to return.
3541 * @return array Post ids.
3542 */
3543 public function find_scan_targets($limit = 5) {
3544 $disabled = $this->get_disabled_post_types();
3545
3546 if ($this->is_remove_everywhere()) {
3547 $disabled = array_keys($this->get_all_post_types());
3548 }
3549
3550 if (empty($disabled)) {
3551 // A rules-only site has nothing disabled by type, so the scanner
3552 // used to answer "no disabled content" to the very people most
3553 // likely to need it — someone whose taxonomy or age rule closed a
3554 // post and whose theme still draws a comment form on it.
3555 //
3556 // Only reached where the old code returned an empty list, so no
3557 // configuration that works today changes behaviour. The candidate
3558 // pool is capped because this walks posts rather than types.
3559 if (!$this->has_conditional_rules()) {
3560 return array();
3561 }
3562
3563 $targets = array();
3564
3565 // From both ends of the archive, not just the newest posts.
3566 // get_posts() defaults to newest-first, and an age rule closes the
3567 // OLDEST posts: a site with fifty posts inside the age window
3568 // returned fifty posts none of which were closed, so the scanner
3569 // reported nothing to scan while every older post was shut. A
3570 // taxonomy or template rule can match anywhere, which the
3571 // newest-first batch samples. Keyed by id so a small site does not
3572 // examine the same post twice.
3573 $candidates = array();
3574
3575 foreach (array('DESC', 'ASC') as $order) {
3576 $batch = get_posts(
3577 array(
3578 'post_type' => array_keys($this->get_all_post_types()),
3579 'post_status' => 'publish',
3580 'numberposts' => 50,
3581 'orderby' => 'date',
3582 'order' => $order,
3583 'suppress_filters' => false,
3584 )
3585 );
3586
3587 foreach ($batch as $post) {
3588 $candidates[(int) $post->ID] = $post;
3589 }
3590 }
3591
3592 foreach ($candidates as $candidate) {
3593 if (count($targets) >= $limit) {
3594 break;
3595 }
3596
3597 if ($this->is_disabled_for_post($candidate->ID)) {
3598 $targets[] = (int) $candidate->ID;
3599 }
3600 }
3601
3602 return $targets;
3603 }
3604
3605 $targets = array();
3606
3607 foreach (array_values($disabled) as $post_type) {
3608 if (count($targets) >= $limit) {
3609 break;
3610 }
3611
3612 $posts = get_posts(
3613 array(
3614 'post_type' => $post_type,
3615 'post_status' => 'publish',
3616 'numberposts' => 1,
3617 'suppress_filters' => false,
3618 )
3619 );
3620
3621 if (!empty($posts)) {
3622 $targets[] = (int) $posts[0]->ID;
3623 }
3624 }
3625
3626 return $targets;
3627 }
3628
3629 /**
3630 * AJAX: run the theme conflict scan.
3631 */
3632 public function scan_theme_conflict() {
3633 $nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');
3634
3635 if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
3636 wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
3637 }
3638
3639 $is_network_ctx = $this->is_network_admin_ajax_context();
3640
3641 // The scan makes the site request its own pages. Gate it like the rest
3642 // of this plugin's admin actions.
3643 if ($is_network_ctx) {
3644 if (!$this->can_network_admin_ajax_context()) {
3645 wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
3646 }
3647 } elseif (!current_user_can('manage_options')) {
3648 wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
3649 }
3650
3651 $post_ids = $this->find_scan_targets();
3652
3653 if (empty($post_ids)) {
3654 wp_send_json_error(
3655 array('message' => __('There is no published content with comments disabled to check yet. Disable comments on a post type that has published content, then run this again.', 'disable-comments')),
3656 400
3657 );
3658 }
3659
3660 $results = array();
3661 $worst = null;
3662
3663 foreach ($post_ids as $post_id) {
3664 $scan = $this->run_theme_scan(get_permalink($post_id));
3665
3666 if (is_wp_error($scan)) {
3667 // A loopback that fails once will fail for every page, so there
3668 // is nothing to learn from continuing.
3669 wp_send_json_error(array('message' => $scan->get_error_message()), 200);
3670 }
3671
3672 $scan = $this->interpret_theme_scan($scan);
3673 $scan['post_id'] = $post_id;
3674 $results[] = $scan;
3675
3676 // Report the page with the real problem, not whichever happened to
3677 // be checked first - a "clean" verdict from one template is exactly
3678 // how a scanner tells somebody there is nothing to fix while the
3679 // page they are looking at is broken.
3680 if (null === $worst || $this->scan_verdict_rank($scan['verdict']) > $this->scan_verdict_rank($worst['verdict'])) {
3681 $worst = $scan;
3682 }
3683 }
3684
3685 $worst['checked'] = count($results);
3686 $worst['all_results'] = $results;
3687
3688 wp_send_json_success($worst);
3689 }
3690
3691 /**
3692 * Settings that travel between sites, and how to clean each one.
3693 *
3694 * Anything not listed here is site-specific or internal and is deliberately
3695 * not portable: `disabled_sites` names blog ids that mean nothing
3696 * elsewhere, `db_version` and `settings_saved` are bookkeeping, and
3697 * `sitewide_settings` lives in a network option rather than these.
3698 *
3699 * @return array Option key => type ('bool', 'int', 'keys', 'strings').
3700 */
3701 private function get_portable_settings_map() {
3702 return array(
3703 'remove_everywhere' => 'bool',
3704 'disabled_post_types' => 'keys',
3705 'extra_post_types' => 'keys',
3706 'remove_xmlrpc_comments' => 'int',
3707 'remove_rest_API_comments' => 'int',
3708 'show_existing_comments' => 'bool',
3709 'allowed_comment_types' => 'keys',
3710 'blocked_comment_types' => 'keys',
3711 'enable_exclude_by_role' => 'bool',
3712 'exclude_by_role' => 'keys',
3713 // Conditional rules ship in the same release as this exporter, and
3714 // leaving them out made the file quietly incomplete: the screen
3715 // says the settings were copied, the destination gets no taxonomy,
3716 // template or age rules, and behaves differently for reasons
3717 // nothing on either site explains.
3718 'enable_conditional_rules' => 'bool',
3719 'conditional_rules' => 'rules',
3720 'auto_close_days' => 'int',
3721 );
3722 }
3723
3724 /**
3725 * Coerce one imported value to the type its option expects.
3726 *
3727 * An import is an uploaded file: every value is treated as hostile, and
3728 * anything that cannot be coerced becomes the empty form of its type
3729 * rather than reaching the options as-is.
3730 *
3731 * @param mixed $value Raw value.
3732 * @param string $type Type from get_portable_settings_map().
3733 * @return mixed Sanitized value.
3734 */
3735 private function sanitize_portable_value($value, $type) {
3736 if ('bool' === $type) {
3737 // PHP treats every non-empty string except "0" as true, so a
3738 // producer that encoded booleans as text would have
3739 // "remove_everywhere":"false" disable comments everywhere. This
3740 // importer already coerces malformed types; it has to coerce this
3741 // one correctly too.
3742 if (is_string($value)) {
3743 return in_array(strtolower(trim($value)), array('1', 'true', 'yes', 'on'), true);
3744 }
3745
3746 return (bool) $value;
3747 }
3748
3749 if ('int' === $type) {
3750 return (int) $value;
3751 }
3752
3753 if ('rules' === $type) {
3754 // Straight through the same sanitiser the settings screen uses, so
3755 // an imported rule cannot be shaped in a way a saved one could not.
3756 // It already tolerates null and non-arrays, which is what a missing
3757 // field arrives as.
3758 return array_values($this->sanitize_conditional_rules($value));
3759 }
3760
3761 if ('keys' === $type) {
3762 if (!is_array($value)) {
3763 return array();
3764 }
3765
3766 $clean = array();
3767 foreach ($value as $item) {
3768 if (!is_scalar($item)) {
3769 continue;
3770 }
3771 $key = sanitize_key($item);
3772 if ('' !== $key) {
3773 $clean[] = $key;
3774 }
3775 }
3776
3777 return array_values(array_unique($clean));
3778 }
3779
3780 return '';
3781 }
3782
3783 /**
3784 * The current configuration as a portable payload.
3785 *
3786 * @return array
3787 */
3788 public function export_settings($is_network_ctx = false) {
3789 $settings = array();
3790 $stored = $this->get_stored_settings($is_network_ctx);
3791
3792 foreach ($this->get_portable_settings_map() as $key => $type) {
3793 $value = isset($stored[$key]) ? $stored[$key] : null;
3794 $settings[$key] = $this->sanitize_portable_value($value, $type);
3795
3796 if ('conditional_rules' === $key) {
3797 $settings[$key] = $this->rules_to_portable($settings[$key]);
3798 }
3799 }
3800
3801 return array(
3802 'schema_version' => self::EXPORT_SCHEMA_VERSION,
3803 'plugin_version' => defined('DC_VERSION') ? DC_VERSION : '',
3804 'exported_at' => gmdate('c'),
3805 'settings' => $settings,
3806 );
3807 }
3808
3809 /**
3810 * Read an import payload, whatever form it arrived in.
3811 *
3812 * @param mixed $payload JSON string or already-decoded array.
3813 * @return array|WP_Error Sanitized settings, or an error naming the problem.
3814 */
3815 private function parse_settings_payload($payload) {
3816 if (is_string($payload)) {
3817 $payload = json_decode($payload, true);
3818 }
3819
3820 if (!is_array($payload)) {
3821 return new WP_Error('dc_import_invalid', __('That file is not valid JSON.', 'disable-comments'));
3822 }
3823
3824 if (!isset($payload['settings']) || !is_array($payload['settings'])) {
3825 return new WP_Error('dc_import_no_settings', __('That file does not contain Disable Comments settings.', 'disable-comments'));
3826 }
3827
3828 $schema = isset($payload['schema_version']) ? (int) $payload['schema_version'] : 0;
3829
3830 if ($schema > self::EXPORT_SCHEMA_VERSION) {
3831 return new WP_Error(
3832 'dc_import_newer_schema',
3833 sprintf(
3834 /* translators: 1: schema version in the file, 2: schema version this plugin understands. */
3835 __('That file was written by a newer version of Disable Comments (format %1$d; this site understands %2$d). Update the plugin first.', 'disable-comments'),
3836 $schema,
3837 self::EXPORT_SCHEMA_VERSION
3838 )
3839 );
3840 }
3841
3842 $clean = array();
3843 foreach ($this->get_portable_settings_map() as $key => $type) {
3844 if (!array_key_exists($key, $payload['settings'])) {
3845 continue;
3846 }
3847 $incoming = $payload['settings'][$key];
3848
3849 // Before sanitising, not after: sanitize_conditional_rules()
3850 // intval()s terms, which would turn every portable slug into 0.
3851 if ('conditional_rules' === $key) {
3852 $incoming = $this->rules_from_portable($incoming);
3853 }
3854
3855 $clean[$key] = $this->sanitize_portable_value($incoming, $type);
3856 }
3857
3858 if (empty($clean)) {
3859 return new WP_Error('dc_import_no_known_keys', __('That file contains no settings this version recognises.', 'disable-comments'));
3860 }
3861
3862 return $clean;
3863 }
3864
3865 /**
3866 * The full portable configuration this import would leave behind.
3867 *
3868 * Built before anything is diffed or written, because normalisation can
3869 * move values between fields: a preview computed from the raw payload
3870 * describes values that were never stored.
3871 *
3872 * @param array $incoming Sanitized incoming settings.
3873 * @param bool $is_network_ctx Whether this import targets network storage.
3874 * @return array Portable key => value.
3875 */
3876 private function build_import_target($incoming, $is_network_ctx) {
3877 $target = array();
3878
3879 // Role exclusion is a per-site rule and the network settings screen
3880 // deliberately does not offer it. Letting a single-site export write it
3881 // into the network option would apply an invisible rule to every
3882 // subsite - "exclude logged-out users" leaves comments open to the
3883 // public everywhere, with nothing on the network screen to explain why.
3884 $per_site_only = array('enable_exclude_by_role', 'exclude_by_role');
3885
3886 // Everything the payload omits is carried over from what is stored,
3887 // not from the effective config this request happens to be running
3888 // with - see get_stored_settings().
3889 $stored = $this->get_stored_settings($is_network_ctx);
3890
3891 foreach ($this->get_portable_settings_map() as $key => $type) {
3892 if ($is_network_ctx && in_array($key, $per_site_only, true)) {
3893 $target[$key] = $this->sanitize_portable_value(
3894 isset($stored[$key]) ? $stored[$key] : null,
3895 $type
3896 );
3897 continue;
3898 }
3899
3900 $current = isset($stored[$key]) ? $stored[$key] : null;
3901 $target[$key] = array_key_exists($key, $incoming)
3902 ? $incoming[$key]
3903 : $this->sanitize_portable_value($current, $type);
3904 }
3905
3906 // Only when the payload actually carries post types. Re-splitting the
3907 // site's existing lists on an unrelated partial import would drop any
3908 // disabled custom type that happens to be unregistered right now -
3909 // silently re-enabling its comments once it is registered again.
3910 $touches_post_types = array_key_exists('disabled_post_types', $incoming)
3911 || array_key_exists('extra_post_types', $incoming);
3912
3913 if ($touches_post_types) {
3914 $target = $this->normalize_post_type_fields($target, $is_network_ctx);
3915 }
3916
3917 return $target;
3918 }
3919
3920 /**
3921 * Re-split post-type fields for this site's activation mode.
3922 *
3923 * The two fields mean different things depending on how the plugin is
3924 * activated: get_disabled_post_types() only merges `extra_post_types` when
3925 * network active, and a network context restricts `disabled_post_types` to
3926 * built-ins. Copying both across verbatim loses custom post types in either
3927 * direction, so both lists are merged and re-split by what this site can
3928 * actually answer.
3929 *
3930 * @param array $target Portable values being assembled.
3931 * @param bool $is_network_ctx Whether this import targets network storage.
3932 * @return array $target with the two post-type fields rewritten.
3933 */
3934 private function normalize_post_type_fields($target, $is_network_ctx) {
3935 $all = array_values(array_unique(array_merge(
3936 isset($target['disabled_post_types']) ? (array) $target['disabled_post_types'] : array(),
3937 isset($target['extra_post_types']) ? (array) $target['extra_post_types'] : array()
3938 )));
3939 $known = array_keys($this->get_all_post_types($is_network_ctx));
3940
3941 $target['disabled_post_types'] = array_values(array_intersect($all, $known));
3942
3943 $leftover = array_values(array_diff($all, $known));
3944
3945 if ($this->networkactive) {
3946 // Network storage is where a custom slug belongs; it is applied
3947 // per-site by get_disabled_post_types() wherever it is registered.
3948 $target['extra_post_types'] = $leftover;
3949 } else {
3950 // A single site has nowhere to keep a slug it does not register.
3951 // diff_settings() reports these as unknown_post_types.
3952 $target['extra_post_types'] = array();
3953 }
3954
3955 return $target;
3956 }
3957
3958 /**
3959 * What would this import change?
3960 *
3961 * Diffed against the normalized target rather than the raw payload, so the
3962 * preview and the applied report both describe values that will actually
3963 * be stored.
3964 *
3965 * @param array $target The configuration this import would leave behind.
3966 * @param array $incoming The sanitized payload, for the unknown-type report.
3967 * @return array {
3968 * @type array $changes Key => array(from, to) for values that differ.
3969 * @type array $unknown_post_types Slugs the destination site does not register.
3970 * }
3971 */
3972 private function diff_settings($target, $incoming, $is_network_ctx = false) {
3973 $changes = array();
3974 $unknown_post_types = array();
3975 // The same stored row build_import_target() worked from. Diffing
3976 // against the effective config instead would report every field the
3977 // constructor blanked as a change this file is about to make, when the
3978 // file leaves it exactly as it is.
3979 $stored = $this->get_stored_settings($is_network_ctx);
3980
3981 foreach ($target as $key => $value) {
3982 $current = isset($stored[$key]) ? $stored[$key] : null;
3983 $current = $this->sanitize_portable_value($current, $this->get_portable_settings_map_type($key));
3984
3985 if ($current !== $value) {
3986 $changes[$key] = array(
3987 'from' => $current,
3988 'to' => $value,
3989 );
3990 }
3991 }
3992
3993 // A post type present on the source and missing here is the single most
3994 // likely way an import quietly does less than the user expects, so it
3995 // is surfaced rather than dropped. Only slugs the payload actually
3996 // asked for - values retained from this site are not "incoming".
3997 $requested = array_merge(
3998 isset($incoming['disabled_post_types']) ? (array) $incoming['disabled_post_types'] : array(),
3999 isset($incoming['extra_post_types']) ? (array) $incoming['extra_post_types'] : array()
4000 );
4001
4002 if (!empty($requested)) {
4003 $registered = array_keys($this->get_all_post_types());
4004
4005 foreach (array_unique($requested) as $slug) {
4006 if (!in_array($slug, $registered, true)) {
4007 $unknown_post_types[] = $slug;
4008 }
4009 }
4010 }
4011
4012 return array(
4013 'changes' => $changes,
4014 'unknown_post_types' => $unknown_post_types,
4015 );
4016 }
4017
4018 /**
4019 * Declared type for one portable setting.
4020 *
4021 * @param string $key Option key.
4022 * @return string Type, or '' when the key is not portable.
4023 */
4024 private function get_portable_settings_map_type($key) {
4025 $map = $this->get_portable_settings_map();
4026
4027 return isset($map[$key]) ? $map[$key] : '';
4028 }
4029
4030 /**
4031 * Apply an import.
4032 *
4033 * @param mixed $payload JSON string or decoded array.
4034 * @param bool $dry_run When true, report the diff and write nothing.
4035 * @param bool $is_network_ctx Whether this runs in a network admin context.
4036 * @return array|WP_Error Diff plus an `applied` flag, or an error.
4037 */
4038 public function import_settings($payload, $dry_run = false, $is_network_ctx = false) {
4039 $incoming = $this->parse_settings_payload($payload);
4040
4041 if (is_wp_error($incoming)) {
4042 return $incoming;
4043 }
4044
4045 $target = $this->build_import_target($incoming, $is_network_ctx);
4046 $diff = $this->diff_settings($target, $incoming, $is_network_ctx);
4047
4048 if ($dry_run) {
4049 $diff['applied'] = false;
4050 return $diff;
4051 }
4052
4053 // Nothing to write, so nothing to purge. Applying an identical file
4054 // used to rewrite the options and invalidate every subsite's page cache
4055 // while reporting no changes.
4056 if (empty($diff['changes'])) {
4057 $diff['applied'] = false;
4058
4059 return $diff;
4060 }
4061
4062 // Compose onto the stored row, not onto $this->options: update_options()
4063 // writes the whole array, so starting from a blanked effective config
4064 // would persist its defaults for every key outside the portable map -
4065 // disabled_sites among them, wiping the network's per-site exclusions
4066 // as a side effect of importing one flag.
4067 $stored = $this->get_stored_settings($is_network_ctx);
4068
4069 foreach ($target as $key => $value) {
4070 $stored[$key] = $value;
4071 }
4072
4073 $stored['db_version'] = self::DB_VERSION;
4074 $stored['settings_saved'] = true;
4075
4076 $this->options = $stored;
4077
4078 $this->update_options($is_network_ctx);
4079
4080 // An import changes what the front end renders, exactly like a save.
4081 $this->purge_page_caches($this->get_purge_blog_ids($is_network_ctx));
4082
4083 $diff['applied'] = true;
4084
4085 return $diff;
4086 }
4087
4088 /**
4089 * AJAX: download the current settings as JSON.
4090 */
4091 public function export_settings_download() {
4092 $nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');
4093
4094 if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
4095 wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
4096 }
4097
4098 // Same gate as the import. The context comes from a GET parameter, so a
4099 // subsite administrator holding a valid nonce from their own screen can
4100 // ask for the network context - and the constructor will already have
4101 // loaded the network option by the time we get here. Reading it needs
4102 // the network capability, exactly as writing it does.
4103 $is_network_ctx = $this->is_network_admin_ajax_context();
4104
4105 if (!current_user_can($this->get_required_import_cap($is_network_ctx))) {
4106 wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
4107 }
4108
4109 nocache_headers();
4110 header('Content-Type: application/json; charset=' . get_option('blog_charset'));
4111 header('Content-Disposition: attachment; filename=disable-comments-settings-' . gmdate('Y-m-d') . '.json');
4112
4113 echo wp_json_encode($this->export_settings($is_network_ctx));
4114
4115 exit;
4116 }
4117
4118 /**
4119 * AJAX: preview or apply an uploaded settings file.
4120 */
4121 public function import_settings_ajax() {
4122 $nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');
4123
4124 if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
4125 wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
4126 }
4127
4128 $is_network_ctx = $this->is_network_admin_ajax_context();
4129
4130 // An import writes the plugin's configuration, so it is gated exactly
4131 // like a save - including the network sitewide lock.
4132 if (!current_user_can($this->get_required_import_cap($is_network_ctx))) {
4133 wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
4134 }
4135
4136 // Not sanitize_text_field(): this is a JSON document and that would
4137 // mangle it. It is validated key by key in parse_settings_payload()
4138 // instead, which is the only thing that makes it safe.
4139 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
4140 $payload = isset($_POST['payload']) ? wp_unslash($_POST['payload']) : '';
4141 $dry_run = !empty($_POST['dry_run']);
4142
4143 $result = $this->import_settings($payload, $dry_run, $is_network_ctx);
4144
4145 if (is_wp_error($result)) {
4146 wp_send_json_error(array('message' => $result->get_error_message()), 400);
4147 }
4148
4149 wp_send_json_success($result);
4150 }
4151
4152 /**
4153 * Capability required to import settings in the current context.
4154 *
4155 * @param bool $is_network_ctx Whether the request came from a network admin screen.
4156 * @return string Capability name.
4157 */
4158 private function get_required_import_cap($is_network_ctx) {
4159 if ($is_network_ctx) {
4160 return 'manage_network_plugins';
4161 }
4162
4163 if ($this->networkactive && $this->sitewide_settings === '1') {
4164 return 'manage_network_plugins';
4165 }
4166
4167 return 'manage_options';
4168 }
4169
4170 public function get_form_array_escaped($_args = array()) {
4171 $formArray = [];
4172 if (!empty($_args)) {
4173 $formArray = wp_parse_args($_args);
4174 }
4175 // nonce is verified in the calling function
4176 // phpcs:ignore WordPress.Security.NonceVerification.Missing
4177 else if (isset($_POST['data'])) {
4178 // need to use wp_parse_args before map_deep sanitize_text_field
4179 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
4180 $formArray = map_deep(wp_parse_args(wp_unslash($_POST['data'])), 'sanitize_text_field');
4181 }
4182 return $formArray;
4183 }
4184
4185 public function disable_comments_settings($_args = array()) {
4186 $nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');
4187 if (($this->is_CLI && !empty($_args)) || wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
4188 // Resolve context ONCE — used for both cap check and save routing.
4189 $is_network_ctx = $this->is_network_admin_ajax_context();
4190
4191 if (!$this->is_CLI) {
4192 if ($is_network_ctx) {
4193 // Network admin context → must be super admin.
4194 $required_cap = 'manage_network_plugins';
4195 } elseif ($this->networkactive && $this->sitewide_settings === '1') {
4196 // Sitewide lock is on → only super admin may write.
4197 $required_cap = 'manage_network_plugins';
4198 } else {
4199 // Subsite or single-site → manage_options suffices.
4200 $required_cap = 'manage_options';
4201 }
4202 if (!current_user_can($required_cap)) {
4203 wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
4204 }
4205 }
4206
4207 $this->apply_settings($this->get_form_array_escaped($_args), $is_network_ctx, $this->is_CLI);
4208 }
4209 if (!$this->is_CLI) {
4210 wp_send_json_success(array('message' => __('Saved', 'disable-comments')));
4211 wp_die();
4212 }
4213 }
4214
4215 /**
4216 * Write a settings payload. Does not verify permission and does not respond.
4217 *
4218 * disable_comments_settings() is an AJAX endpoint: it authenticates, calls
4219 * this, then terminates the request with a JSON envelope. Any caller that
4220 * has done its own authentication — the Abilities API, for one — has to
4221 * come in here instead, or it gets wp_die()'d before it can return a
4222 * result, having changed nothing.
4223 *
4224 * @param array $formArray Parsed settings payload.
4225 * @param bool $is_network_ctx Whether to route storage to the network option.
4226 * @param bool $preserve_existing Keep the current value of anything the
4227 * payload omits, rather than clearing it.
4228 * The settings screen posts every field, so
4229 * it wants false; a partial programmatic
4230 * update wants true.
4231 * @return void
4232 */
4233 public function apply_settings($formArray, $is_network_ctx = false, $preserve_existing = false) {
4234 {
4235 $old_options = $this->options;
4236 $this->options = [];
4237 if ($preserve_existing) {
4238 $this->options = $old_options;
4239 }
4240
4241 if ($is_network_ctx && function_exists('get_sites') && empty($formArray['sitewide_settings'])) {
4242 $formArray['disabled_sites'] = isset($formArray['disabled_sites']) ? $formArray['disabled_sites'] : [];
4243 $this->options['disabled_sites'] = isset($old_options['disabled_sites']) ? $old_options['disabled_sites'] : [];
4244 $this->options['disabled_sites'] = array_merge($this->options['disabled_sites'], $formArray['disabled_sites']);
4245 } elseif ($is_network_ctx && !empty($formArray['sitewide_settings'])) {
4246 $this->options['disabled_sites'] = $old_options['disabled_sites'];
4247 }
4248
4249 if (isset($formArray['mode'])) {
4250 $this->options['remove_everywhere'] = (sanitize_text_field($formArray['mode']) == 'remove_everywhere');
4251 }
4252 $post_types = $this->get_all_post_types($is_network_ctx);
4253
4254 if ($this->options['remove_everywhere']) {
4255 $disabled_post_types = array_keys($post_types);
4256 } else {
4257 $disabled_post_types = (isset($formArray['disabled_types']) ? array_map('sanitize_key', (array) $formArray['disabled_types']) : ($preserve_existing && isset($this->options['disabled_post_types']) ? $this->options['disabled_post_types'] : []));
4258 }
4259
4260 $disabled_post_types = array_intersect($disabled_post_types, array_keys($post_types));
4261 $this->options['disabled_post_types'] = $disabled_post_types;
4262
4263 // Extra custom post types.
4264 if ($this->networkactive && isset($formArray['extra_post_types'])) {
4265 $extra_post_types = array_filter(array_map('sanitize_key', explode(',', $formArray['extra_post_types'])));
4266 $this->options['extra_post_types'] = array_diff($extra_post_types, array_keys($post_types)); // Make sure we don't double up builtins.
4267 }
4268
4269 if ($is_network_ctx && isset($formArray['sitewide_settings'])) {
4270 update_site_option('disable_comments_sitewide_settings', $formArray['sitewide_settings']);
4271 }
4272
4273 if (isset($formArray['disable_avatar'])) {
4274 if ($is_network_ctx) {
4275 if ($formArray['disable_avatar'] == '0' || $formArray['disable_avatar'] == '1') {
4276 $sites = get_sites([
4277 'number' => 0,
4278 'fields' => 'ids',
4279 ]);
4280 foreach ($sites as $blog_id) {
4281 switch_to_blog($blog_id);
4282 update_option('show_avatars', (bool) !$formArray['disable_avatar']);
4283 restore_current_blog();
4284 }
4285 }
4286 } else {
4287 update_option('show_avatars', (bool) !$formArray['disable_avatar']);
4288 }
4289 }
4290
4291 if (isset($formArray['enable_exclude_by_role'])) {
4292 $this->options['enable_exclude_by_role'] = $formArray['enable_exclude_by_role'];
4293 }
4294 if (isset($formArray['exclude_by_role'])) {
4295 $this->options['exclude_by_role'] = $formArray['exclude_by_role'];
4296 }
4297
4298 // xml rpc
4299 $this->options['remove_xmlrpc_comments'] = (isset($formArray['remove_xmlrpc_comments']) ? intval($formArray['remove_xmlrpc_comments']) : ($preserve_existing && isset($this->options['remove_xmlrpc_comments']) ? $this->options['remove_xmlrpc_comments'] : 0));
4300 // rest api comments
4301 $this->options['remove_rest_API_comments'] = (isset($formArray['remove_rest_API_comments']) ? intval($formArray['remove_rest_API_comments']) : ($preserve_existing && isset($this->options['remove_rest_API_comments']) ? $this->options['remove_rest_API_comments'] : 0));
4302 // show existing comments
4303 $this->options['show_existing_comments'] = (isset($formArray['show_existing_comments']) ? (bool) $formArray['show_existing_comments'] : ($preserve_existing && isset($this->options['show_existing_comments']) ? $this->options['show_existing_comments'] : false));
4304
4305 // conditional rules: taxonomy / template overrides plus auto-close
4306 //
4307 // $preserve_existing, not $this->is_CLI. These three fallbacks were
4308 // written when WP-CLI was the only partial-update caller and the
4309 // two flags meant the same thing. They no longer do: an Abilities
4310 // API set-status is a partial update that is explicitly NOT CLI, so
4311 // keying on is_CLI meant a call changing only the base mode wiped
4312 // the site's whole rules configuration — enable off, rules emptied,
4313 // auto-close reset — without saying so.
4314 $this->options['enable_conditional_rules'] = (isset($formArray['enable_conditional_rules']) ? (bool) $formArray['enable_conditional_rules'] : ($preserve_existing && isset($old_options['enable_conditional_rules']) ? $old_options['enable_conditional_rules'] : false));
4315
4316 if (isset($formArray['conditional_rules'])) {
4317 $this->options['conditional_rules'] = $this->sanitize_conditional_rules($formArray['conditional_rules']);
4318 } elseif ($preserve_existing && isset($old_options['conditional_rules'])) {
4319 $this->options['conditional_rules'] = $old_options['conditional_rules'];
4320 } else {
4321 $this->options['conditional_rules'] = array();
4322 }
4323
4324 $this->options['auto_close_days'] = (isset($formArray['auto_close_days']) ? max(0, intval($formArray['auto_close_days'])) : ($preserve_existing && isset($old_options['auto_close_days']) ? (int) $old_options['auto_close_days'] : 0));
4325
4326 // allowed comment types (opt-in allowlist)
4327 if (isset($formArray['allowed_comment_types']) && is_array($formArray['allowed_comment_types'])) {
4328 // Sanitize and validate the allowed comment types
4329 $this->options['allowed_comment_types'] = array_map('sanitize_key', $formArray['allowed_comment_types']);
4330 } elseif ($preserve_existing && isset($old_options['allowed_comment_types'])) {
4331 // A partial update must not silently un-protect the comment
4332 // types someone deliberately allowlisted. The settings screen
4333 // always posts this field, so it still clears on a real save.
4334 $this->options['allowed_comment_types'] = $old_options['allowed_comment_types'];
4335 } else {
4336 // Default: empty array (all special comment types disabled)
4337 $this->options['allowed_comment_types'] = array();
4338 }
4339
4340 // blocked comment types (opt-in blocklist - the reverse of the
4341 // allowlist: these close even where comments are open)
4342 if (isset($formArray['blocked_comment_types']) && is_array($formArray['blocked_comment_types'])) {
4343 $this->options['blocked_comment_types'] = array_map('sanitize_key', $formArray['blocked_comment_types']);
4344 } elseif ($preserve_existing && isset($old_options['blocked_comment_types'])) {
4345 // Same reasoning as the allowlist above: a partial update must
4346 // not silently reopen a comment type someone closed.
4347 $this->options['blocked_comment_types'] = $old_options['blocked_comment_types'];
4348 } else {
4349 // Default: empty array (nothing closed by type)
4350 $this->options['blocked_comment_types'] = array();
4351 }
4352
4353 // Nothing stops someone ticking the same type in both lists. Store
4354 // the resolution rather than the contradiction, so an export, an
4355 // import and a status report cannot describe a state the plugin
4356 // does not actually implement. get_allowed_comment_types() applies
4357 // the same precedence at read time, for settings written before
4358 // this ran.
4359 if (!empty($this->options['blocked_comment_types'])) {
4360 $this->options['allowed_comment_types'] = array_values(
4361 array_diff((array) $this->options['allowed_comment_types'], $this->options['blocked_comment_types'])
4362 );
4363 }
4364
4365 $this->options['db_version'] = self::DB_VERSION;
4366 $this->options['settings_saved'] = true;
4367 // save settings
4368 $this->update_options($is_network_ctx);
4369
4370 // A cached page keeps serving the old comment form otherwise, so the
4371 // setting looks ignored to every visitor until the cache expires.
4372 // A network save invalidates every site, not just this one.
4373 $this->purge_page_caches($this->get_purge_blog_ids($is_network_ctx));
4374 }
4375 }
4376
4377 public function is_configured() {
4378 $disabled_post_types = $this->get_disabled_post_types();
4379
4380 if (empty($disabled_post_types) && empty($this->options['remove_everywhere']) && empty($this->options['remove_rest_API_comments']) && empty($this->options['remove_xmlrpc_comments']) && !$this->has_conditional_rules() && !$this->has_blocked_comment_types()) {
4381 return false;
4382 }
4383 return true;
4384 }
4385
4386 public function delete_comments_settings($_args = array(), $ceilings = null) {
4387 global $deletedPostTypeNames;
4388 $log = '';
4389 $nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');
4390
4391 if (($this->is_CLI && !empty($_args)) || wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
4392 // Resolve context ONCE — used for both cap check and deletion routing.
4393 $is_network_ctx = $this->is_network_admin_ajax_context();
4394
4395 if (!$this->is_CLI) {
4396 if (!current_user_can($this->get_required_delete_cap($is_network_ctx))) {
4397 wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
4398 }
4399 }
4400
4401 $log = $this->apply_delete_comments($this->get_form_array_escaped($_args), $is_network_ctx, $_args, $ceilings);
4402 }
4403 // message
4404 $deletedPostTypeNames = array_unique((array) $deletedPostTypeNames);
4405 $message = (count($deletedPostTypeNames) == 0 ? $log . '.' : $log . ' for ' . implode(", ", $deletedPostTypeNames) . '.');
4406 if (!$this->is_CLI) {
4407 // `deleted` so the screen can tell "removed nothing" from "removed
4408 // something" without parsing the prose in `message`. A run that
4409 // matched nothing has no aftermath to re-render.
4410 wp_send_json_success(array(
4411 'message' => $message,
4412 'deleted' => $this->get_last_deleted_count(),
4413 ));
4414 wp_die();
4415 } else {
4416 return $log;
4417 }
4418 }
4419
4420 /**
4421 * Run a deletion. Does not verify permission and does not respond.
4422 *
4423 * Same split as apply_settings(): delete_comments_settings() is the AJAX
4424 * endpoint, this is the work it does once the caller is authenticated.
4425 *
4426 * @param array $formArray Parsed delete payload.
4427 * @param bool $is_network_ctx Whether to route deletion across the network.
4428 * @param array $_args Original args, threaded to the inner helper.
4429 * @return string Log message.
4430 */
4431 /**
4432 * How many comments the last delete in this request removed.
4433 *
4434 * Zero both when nothing matched and when no delete has run, which is the
4435 * same thing to every caller: nothing was removed.
4436 *
4437 * @since 2.9.1
4438 * @return int
4439 */
4440 public function get_last_deleted_count() {
4441 return (int) $this->last_deleted_count;
4442 }
4443
4444 public function apply_delete_comments($formArray, $is_network_ctx = false, $_args = array(), $ceilings = null) {
4445 $log = '';
4446
4447 // Counted before and after rather than inferred from the log string,
4448 // so the review prompt can name a real number. It lives here rather
4449 // than in the AJAX wrapper because this is the function that actually
4450 // deletes — which means a delete driven through the Abilities API is
4451 // counted too, and it is just as much a completed bulk delete.
4452 $deleted_count = 0;
4453
4454 {
4455 if ($is_network_ctx && function_exists('get_sites') && class_exists('WP_Site_Query')) {
4456 $sites = get_sites([
4457 'number' => 0,
4458 'fields' => 'ids',
4459 ]);
4460 foreach ($sites as $blog_id) {
4461 // $formArray['disabled_sites'] ids don't include "site_" prefix.
4462 if (!empty($formArray['disabled_sites']) && !empty($formArray['disabled_sites']["site_$blog_id"])) {
4463 switch_to_blog($blog_id);
4464 if (!$this->can_manage_current_site()) {
4465 restore_current_blog();
4466 continue;
4467 }
4468 // Measured inside the switch, like the purge below it:
4469 // each subsite has its own comments table, so counting
4470 // around the whole loop would only ever see the site
4471 // the request landed on — reporting zero for a
4472 // deletion that emptied every subsite.
4473 $before = $this->count_all_comments();
4474
4475 $log = $this->delete_comments($_args, $is_network_ctx, $ceilings);
4476
4477 $deleted_count += max(0, $before - $this->count_all_comments());
4478
4479 // Purge while this site is still switched in: per-site
4480 // integrations only clear the site they run in, so a
4481 // purge after the loop would miss every subsite.
4482 $this->purge_page_caches();
4483 restore_current_blog();
4484 }
4485 }
4486 } else {
4487 $before = $this->count_all_comments();
4488
4489 $log = $this->delete_comments($_args, $is_network_ctx, $ceilings);
4490
4491 $deleted_count = max(0, $before - $this->count_all_comments());
4492
4493 // Deleted comments stay visible in cached pages, and so do
4494 // their counts, so the same purge applies here.
4495 $this->purge_page_caches();
4496 }
4497 }
4498
4499 $this->last_deleted_count = (int) $deleted_count;
4500
4501 // A completed bulk delete is the one moment the plugin has visibly
4502 // earned something. Recorded here, shown later on our own screens.
4503 if (!empty($deleted_count)) {
4504 $this->record_review_trigger($deleted_count);
4505 }
4506
4507 return $log;
4508 }
4509
4510 /**
4511 * Capability required to delete comments in the current context.
4512 *
4513 * Shared by the delete handler, the dry-run preview and the CSV export so
4514 * a caller can never reach the data through a weaker gate than the one
4515 * guarding the deletion itself.
4516 *
4517 * @param bool $is_network_ctx Whether the request came from a network admin screen.
4518 * @return string Capability name.
4519 */
4520 private function get_required_delete_cap($is_network_ctx) {
4521 if ($is_network_ctx) {
4522 // Network admin context -> must be super admin.
4523 return 'manage_network_plugins';
4524 }
4525 if ($this->networkactive && $this->sitewide_settings === '1') {
4526 // Sitewide lock is on -> only super admin may write.
4527 return 'manage_network_plugins';
4528 }
4529 return 'manage_options';
4530 }
4531
4532 /**
4533 * Resolve a delete request into the set of rows it matches.
4534 *
4535 * One place decides what each delete mode actually selects. The preview
4536 * count, the CSV export and the deletion all read their WHERE clause from
4537 * here, so the number shown to the user is by construction the number of
4538 * rows the delete will remove - not an estimate from a similar query.
4539 *
4540 * Each target is:
4541 * label - human-readable name, used in the summary and breakdown
4542 * join - SQL joining $wpdb->comments (aliased `comments`) to posts
4543 * where - SQL predicate carrying %s placeholders
4544 * params - values for those placeholders
4545 * post_type - post type slug when the target is scoped to one, else ''
4546 *
4547 * @param array $formArray Parsed form data.
4548 * @param bool $is_network_ctx Whether this runs in a network admin context.
4549 * @return array List of targets; empty when the mode matches nothing.
4550 */
4551 private function get_delete_targets($formArray, $is_network_ctx = false) {
4552 global $wpdb;
4553
4554 $targets = array();
4555
4556 if (!isset($formArray['delete_mode'])) {
4557 return $targets;
4558 }
4559
4560 $mode = $formArray['delete_mode'];
4561 $types = $this->get_all_post_types($is_network_ctx);
4562 $commenttypes = $this->get_all_comment_types();
4563 $allowed_types = $this->get_allowed_comment_types();
4564
4565 // Allowed comment types (WP 6.9+ notes, and anything a plugin adds to
4566 // the allowlist) are opt-in-preserved. They must survive every mode
4567 // that is not explicitly naming them.
4568 $exclude_allowed = '';
4569 $exclude_params = array();
4570 if (!empty($allowed_types)) {
4571 $placeholders = implode(', ', array_fill(0, count($allowed_types), '%s'));
4572 $exclude_allowed = " AND comments.comment_type NOT IN ($placeholders)";
4573 $exclude_params = $allowed_types;
4574 }
4575
4576 $post_join = " INNER JOIN $wpdb->posts posts ON comments.comment_post_ID=posts.ID";
4577
4578 if ($mode == 'delete_everywhere') {
4579 $targets[] = array(
4580 'label' => __('All comments', 'disable-comments'),
4581 'join' => '',
4582 'where' => '1=1' . $exclude_allowed,
4583 'params' => $exclude_params,
4584 'post_type' => '',
4585 );
4586 } elseif ($mode == 'selected_delete_types') {
4587 $delete_post_types = empty($formArray['delete_types']) ? array() : (array) $formArray['delete_types'];
4588 $delete_post_types = array_intersect($delete_post_types, array_keys($types));
4589
4590 // Extra custom post types.
4591 if ($this->networkactive && !empty($formArray['delete_extra_post_types'])) {
4592 $delete_extra_post_types = array_filter(array_map('sanitize_key', explode(',', $formArray['delete_extra_post_types'])));
4593 $delete_extra_post_types = array_diff($delete_extra_post_types, array_keys($types)); // Make sure we don't double up builtins.
4594 $delete_post_types = array_merge($delete_post_types, $delete_extra_post_types);
4595 }
4596
4597 // Unique last, so it covers the free-text field too: a repeated
4598 // slug - --types=post,post, or book,book typed into the network
4599 // screen's extra-post-types box - would otherwise build two
4600 // identical targets, and the preview and export would count every
4601 // matching comment twice while the delete removes it once.
4602 $delete_post_types = array_values(array_unique($delete_post_types));
4603
4604 foreach ($delete_post_types as $delete_post_type) {
4605 $post_type_object = get_post_type_object($delete_post_type);
4606 $post_type_label = $post_type_object ? $post_type_object->labels->name : $delete_post_type;
4607
4608 $targets[] = array(
4609 'label' => $post_type_label,
4610 'join' => $post_join,
4611 'where' => 'posts.post_type = %s' . $exclude_allowed,
4612 'params' => array_merge(array($delete_post_type), $exclude_params),
4613 'post_type' => $delete_post_type,
4614 );
4615 }
4616 } elseif ($mode == 'selected_delete_comment_types') {
4617 $delete_comment_types = empty($formArray['delete_comment_types']) ? array() : (array) $formArray['delete_comment_types'];
4618 // get_all_comment_types() already drops allowed types, so an
4619 // allowlisted type cannot be selected here in the first place.
4620 // Unique for the same reason as the post types above.
4621 $delete_comment_types = array_values(array_unique(array_intersect($delete_comment_types, array_keys($commenttypes))));
4622
4623 foreach ($delete_comment_types as $delete_comment_type) {
4624 $targets[] = array(
4625 'label' => $commenttypes[$delete_comment_type],
4626 'join' => '',
4627 'where' => 'comments.comment_type = %s',
4628 'params' => array($delete_comment_type),
4629 'post_type' => '',
4630 );
4631 }
4632 } elseif ($mode == 'delete_spam') {
4633 $targets[] = array(
4634 'label' => __('Spam comments', 'disable-comments'),
4635 'join' => '',
4636 'where' => 'comments.comment_approved = %s' . $exclude_allowed,
4637 'params' => array_merge(array('spam'), $exclude_params),
4638 'post_type' => '',
4639 );
4640 }
4641
4642 return $targets;
4643 }
4644
4645 /**
4646 * Build a statement for one target, prepared when it carries parameters.
4647 *
4648 * @param string $prefix Everything up to and including the FROM clause.
4649 * @param array $target A target from get_delete_targets().
4650 * @param string $suffix Optional trailing SQL (ORDER BY, LIMIT).
4651 * @param array $extra Extra placeholder values appended after the target's.
4652 * @return string SQL, prepared where necessary.
4653 */
4654 private function target_signature($target) {
4655 // Identity of one target, stable across the two separate
4656 // get_delete_targets() calls the export and the delete each make.
4657 //
4658 // Deliberately not the array index: the two calls would have to agree
4659 // on ordering forever for that to hold, and if they ever stopped
4660 // agreeing the mistake would be silent and would delete the wrong
4661 // rows. A signature that does not match simply is not found, and an
4662 // unfound target deletes nothing.
4663 return md5($target['where'] . '|' . wp_json_encode($target['params']));
4664 }
4665
4666 private function build_target_query($prefix, $target, $suffix = '', $extra = array()) {
4667 global $wpdb;
4668
4669 $sql = $prefix . $target['join'] . ' WHERE ' . $target['where'] . $suffix;
4670 $params = array_merge($target['params'], $extra);
4671
4672 if (!empty($params)) {
4673 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
4674 $sql = $wpdb->prepare($sql, $params);
4675 }
4676
4677 return $sql;
4678 }
4679
4680 /**
4681 * Count the comments a delete request would remove, without removing them.
4682 *
4683 * @param array $_args Form data (WP-CLI / programmatic callers).
4684 * @param bool $is_network_ctx Whether this runs in a network admin context.
4685 * @return array {
4686 * @type int $total Total rows matched.
4687 * @type array $breakdown Label => count, one entry per target.
4688 * }
4689 */
4690 public function count_comments_for_delete($_args = array(), $is_network_ctx = false, $sample_size = 0) {
4691 $formArray = $this->get_form_array_escaped($_args);
4692
4693 // Opt-in, and off by default: the WP-CLI dry run and the abilities'
4694 // matched count read the total and nothing else, and neither should
4695 // pay for a query whose rows they discard.
4696 $sample_size = max(0, (int) $sample_size);
4697
4698 // The deletion switches into each selected subsite. A preview that
4699 // counted only the current blog would promise a number the delete does
4700 // not honour - which defeats the point of having a preview.
4701 if ($this->is_network_delete($is_network_ctx)) {
4702 $blog_ids = $this->get_selected_delete_blog_ids($formArray, $is_network_ctx);
4703 $breakdown = array();
4704 $sample = array();
4705 $total = 0;
4706
4707 foreach ($blog_ids as $blog_id) {
4708 switch_to_blog($blog_id);
4709
4710 // Same check the export and the delete make. Counting a site
4711 // the delete will skip tells the operator more will be removed
4712 // than can be, and discloses a total from a site they hold no
4713 // rights on.
4714 if (!$this->can_manage_current_site()) {
4715 restore_current_blog();
4716 continue;
4717 }
4718
4719 $site = $this->count_comments_on_current_site($formArray, $is_network_ctx);
4720
4721 // Inside the capability check above, deliberately: a row is a
4722 // comment's author and text, which is a great deal more than
4723 // the count this guard already withholds.
4724 $site_rows = array();
4725 if ($sample_size > count($sample)) {
4726 $site_name = get_bloginfo('name');
4727
4728 foreach ($this->sample_comments_on_current_site($formArray, $is_network_ctx, $sample_size - count($sample)) as $row) {
4729 // Comment and post ids are site-local, so a network
4730 // preview that did not name the site would list rows
4731 // nobody could place.
4732 $row['site'] = $site_name;
4733 $site_rows[] = $row;
4734 }
4735 }
4736
4737 restore_current_blog();
4738
4739 $total += $site['total'];
4740 $sample = array_merge($sample, $site_rows);
4741
4742 foreach ($site['breakdown'] as $label => $count) {
4743 $breakdown[$label] = (isset($breakdown[$label]) ? $breakdown[$label] : 0) + $count;
4744 }
4745 }
4746
4747 return array(
4748 'total' => $total,
4749 'breakdown' => $breakdown,
4750 'sample' => $sample,
4751 );
4752 }
4753
4754 $preview = $this->count_comments_on_current_site($formArray, $is_network_ctx);
4755 $preview['sample'] = $this->sample_comments_on_current_site($formArray, $is_network_ctx, $sample_size);
4756
4757 return $preview;
4758 }
4759
4760 /**
4761 * A few of the comments a delete would actually remove.
4762 *
4763 * The button says "Preview what will be deleted" and the answer was a
4764 * number. A count is not a review: somebody who reads one and confirms has
4765 * checked how many rows will go, not which, and this operation has no undo
4766 * and no trash to recover from. So the preview shows the comments
4767 * themselves - who wrote them, when, on what, and enough of the text to
4768 * recognise.
4769 *
4770 * Newest first, because a delete that is about to take something
4771 * unintended is most often taking something recent.
4772 *
4773 * This discloses nothing new: the caller already holds the capability the
4774 * delete itself requires, and the CSV backup on the same screen hands them
4775 * every column, addresses and IP addresses included.
4776 *
4777 * @param array $formArray Parsed delete payload.
4778 * @param bool $is_network_ctx Whether this is a network admin request.
4779 * @param int $limit How many rows to collect at most.
4780 * @return array List of array('author', 'date', 'post', 'excerpt').
4781 */
4782 private function sample_comments_on_current_site($formArray, $is_network_ctx, $limit) {
4783 global $wpdb;
4784
4785 $limit = (int) $limit;
4786
4787 if ($limit < 1) {
4788 return array();
4789 }
4790
4791 $targets = $this->get_delete_targets($formArray, $is_network_ctx);
4792 $sample = array();
4793
4794 foreach ($targets as $target) {
4795 $remaining = $limit - count($sample);
4796
4797 if ($remaining < 1) {
4798 break;
4799 }
4800
4801 $select = "SELECT comments.comment_author, comments.comment_date, comments.comment_content, comments.comment_post_ID FROM $wpdb->comments comments";
4802 $sql = $this->build_target_query(
4803 $select,
4804 $target,
4805 ' ORDER BY comments.comment_date DESC LIMIT ' . (int) $remaining
4806 );
4807
4808 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
4809 $rows = $wpdb->get_results($sql, ARRAY_A);
4810
4811 // A failed read here would quietly shorten the list, and a short
4812 // list reads as "this is all of it".
4813 $this->assert_query_succeeded();
4814
4815 foreach ((array) $rows as $row) {
4816 $author = trim((string) $row['comment_author']);
4817
4818 $sample[] = array(
4819 'author' => ('' === $author) ? __('Anonymous', 'disable-comments') : $author,
4820 'date' => mysql2date(get_option('date_format'), $row['comment_date']),
4821 'post' => get_the_title((int) $row['comment_post_ID']),
4822 // Tags stripped before truncating: wp_html_excerpt counts
4823 // markup toward its budget, so a comment wrapped in a link
4824 // would come back cut to nothing worth reading.
4825 'excerpt' => wp_html_excerpt(wp_strip_all_tags((string) $row['comment_content']), 100, ''),
4826 );
4827 }
4828 }
4829
4830 return $sample;
4831 }
4832
4833 /**
4834 * The subsites a network delete would actually touch.
4835 *
4836 * Mirrors the loop in delete_comments_settings(). Returns an empty array
4837 * whenever the deletion would run against the current site only, so the
4838 * caller can take the simple path.
4839 *
4840 * @param array $formArray Parsed delete payload.
4841 * @param bool $is_network_ctx Whether this is a network admin request.
4842 * @return array Blog ids.
4843 */
4844 private function get_selected_delete_blog_ids($formArray, $is_network_ctx) {
4845 if (!$is_network_ctx || !function_exists('get_sites') || !class_exists('WP_Site_Query')) {
4846 return array();
4847 }
4848
4849 $selected = array();
4850 foreach (get_sites(array('number' => 0, 'fields' => 'ids')) as $blog_id) {
4851 if (!empty($formArray['disabled_sites']["site_$blog_id"])) {
4852 $selected[] = (int) $blog_id;
4853 }
4854 }
4855
4856 // Returns an empty array only for a non-network request. A network
4857 // request with nothing ticked returns an empty list too, but the caller
4858 // distinguishes them via is_network_delete() - the deletion loops over
4859 // the selection and touches nothing, so preview and export must scope
4860 // to nothing as well rather than silently reporting the current site.
4861 return $selected;
4862 }
4863
4864 private function can_manage_current_site() {
4865 // Whether the current user may act on whichever blog is switched in.
4866 //
4867 // One helper for all three network loops - preview, export, delete -
4868 // because they have to agree. They are the same operation seen at
4869 // three moments, and each time they have drifted apart the preview or
4870 // the export has ended up covering a site the delete refuses to touch.
4871 return (is_super_admin() || current_user_can('manage_options'));
4872 }
4873
4874 /**
4875 * Is this a network-scoped delete request?
4876 *
4877 * @param bool $is_network_ctx Whether this is a network admin request.
4878 * @return bool
4879 */
4880 private function is_network_delete($is_network_ctx) {
4881 return (bool) ($is_network_ctx && function_exists('get_sites') && class_exists('WP_Site_Query'));
4882 }
4883
4884 /**
4885 * Highest comment id written per blog by the last export, or null.
4886 *
4887 * @var array|null
4888 */
4889 private $last_export_ceilings = null;
4890
4891 /**
4892 * The ceiling the last export reached, for the delete that follows it.
4893 *
4894 * Null when no export has run in this request — meaning the delete is not
4895 * working from a backup and has nothing to constrain itself to.
4896 *
4897 * @return array|null blog_id => highest comment id exported.
4898 */
4899 public function get_last_export_ceilings() {
4900 return $this->last_export_ceilings;
4901 }
4902
4903 private function assert_query_succeeded() {
4904 global $wpdb;
4905
4906 // wpdb answers a failed read the same way it answers an empty table:
4907 // get_results() gives an empty array, get_var() gives null. Every
4908 // caller here is deciding either what to tell the operator will be
4909 // deleted, or whether a backup is complete enough to delete against —
4910 // so "the replica went away" must never arrive as "there is nothing
4911 // there". last_error is the only thing that tells them apart.
4912 //
4913 // wpdb::query() calls flush() before it runs, which clears last_error,
4914 // so this cannot pick up a stale failure from an earlier query.
4915 if ('' !== $wpdb->last_error) {
4916 throw new RuntimeException('Database error while reading comments: ' . $wpdb->last_error);
4917 }
4918 }
4919
4920 /**
4921 * Count a delete request's matches on whichever site is current.
4922 *
4923 * @param array $formArray Parsed delete payload.
4924 * @param bool $is_network_ctx Whether this is a network admin request.
4925 * @return array total + breakdown.
4926 */
4927 private function count_comments_on_current_site($formArray, $is_network_ctx) {
4928 global $wpdb;
4929
4930 $targets = $this->get_delete_targets($formArray, $is_network_ctx);
4931
4932 $breakdown = array();
4933 $total = 0;
4934
4935 foreach ($targets as $target) {
4936 $sql = $this->build_target_query("SELECT COUNT(*) FROM $wpdb->comments comments", $target);
4937 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
4938 $count = (int) $wpdb->get_var($sql);
4939 // A failed count reads as zero, and zero is the number the
4940 // operator confirms the delete against.
4941 $this->assert_query_succeeded();
4942
4943 $label = $target['label'];
4944 $breakdown[$label] = (isset($breakdown[$label]) ? $breakdown[$label] : 0) + $count;
4945 $total += $count;
4946 }
4947
4948 return array(
4949 'total' => $total,
4950 'breakdown' => $breakdown,
4951 );
4952 }
4953
4954 /**
4955 * Make a value safe to hand to a spreadsheet.
4956 *
4957 * A comment body is attacker-controlled text. Excel and Sheets execute a
4958 * cell that opens with =, +, - or @, so exporting one verbatim turns a
4959 * backup into a payload delivery mechanism. The usual guard is a leading
4960 * apostrophe, which those programs treat as "the rest is text".
4961 *
4962 * But this file is also offered as a backup to restore from, and a guard
4963 * that cannot be undone corrupts what it protects. Prefixing alone is
4964 * ambiguous: a body of +1 and a body of '+1 both come out as '+1, so a
4965 * restore cannot tell which it started from.
4966 *
4967 * So an apostrophe is escaped by doubling it, exactly as the format
4968 * already does with quotes. The rule to reverse it is: if the value starts
4969 * with an apostrophe, drop that one character. +1 becomes '+1 and restores
4970 * to +1; '+1 becomes ''+1 and restores to '+1. Every value round-trips.
4971 *
4972 * @param string $value Raw cell value.
4973 * @return string Value safe to write, and reversible by the rule above.
4974 */
4975 public function csv_escape($value) {
4976 $value = (string) $value;
4977
4978 if ('' === $value) {
4979 return $value;
4980 }
4981
4982 $first = substr($value, 0, 1);
4983
4984 // "'" is here for the round trip, not for the spreadsheet: without it
4985 // the guard is a one-way transformation.
4986 if (false !== strpos("=+-@'\t\r", $first)) {
4987 return "'" . $value;
4988 }
4989
4990 return $value;
4991 }
4992
4993 /**
4994 * Undo csv_escape(), for anyone restoring from one of these files.
4995 *
4996 * Public so a restore script does not have to reimplement the rule and get
4997 * it subtly wrong.
4998 *
4999 * @param string $value Value as it appears in the CSV.
5000 * @return string The original value.
5001 */
5002 public function csv_unescape($value) {
5003 $value = (string) $value;
5004
5005 if ('' !== $value && "'" === substr($value, 0, 1)) {
5006 return substr($value, 1);
5007 }
5008
5009 return $value;
5010 }
5011
5012 /**
5013 * Format one CSV record.
5014 *
5015 * Written by hand rather than with fputcsv(), whose backslash escape
5016 * character RFC 4180 has no concept of. fputcsv() doubles a quote for the
5017 * enclosure but leaves a backslash in front of it alone, so a comment
5018 * containing \" - a regex, a pasted code snippet - is written as \"",
5019 * where a conforming reader takes the backslash for ordinary text and the
5020 * quote after it for the end of the field. Every column past that one
5021 * shifts, and the CLI can go on to delete the comments against a backup
5022 * that no longer lines up. Passing '' disables the escape, but only on
5023 * PHP 7.4 and up, and this file has to run back to 5.6.
5024 *
5025 * So: quote every field, double any quote inside it, and nothing else is
5026 * special. That is the whole of the format.
5027 *
5028 * One caveat for anyone reading this file back: PHP's own fgetcsv() and
5029 * str_getcsv() default to that same non-standard backslash escape, so
5030 * they need an explicit '' escape to read conforming CSV. Spreadsheet
5031 * software and other languages' parsers need nothing.
5032 *
5033 * @param array $fields Values for one record.
5034 * @return string The record, terminated.
5035 */
5036 private function csv_write($handle, $line) {
5037 $expected = strlen($line);
5038 $written = fwrite($handle, $line);
5039
5040 // fwrite() reports a short count as an integer, not false: a disk that
5041 // fills mid-row returns the bytes it managed. Treating that as success
5042 // leaves a truncated backup the CLI then deletes against, so anything
5043 // short of the whole line is a failure.
5044 return (false !== $written && $written === $expected);
5045 }
5046
5047 /**
5048 * Format one CSV record.
5049 *
5050 * See csv_line()'s companion csv_write() for why writes are length-checked.
5051 *
5052 * @param array $fields Values for one record.
5053 * @return string The record, terminated.
5054 */
5055 private function csv_line($fields) {
5056 $out = array();
5057
5058 foreach ($fields as $field) {
5059 $out[] = '"' . str_replace('"', '""', (string) $field) . '"';
5060 }
5061
5062 return implode(',', $out) . "\n";
5063 }
5064
5065 /**
5066 * Comment meta for a batch of comments, keyed by comment id.
5067 *
5068 * Fetched per batch rather than per row: one query for five hundred
5069 * comments instead of five hundred.
5070 *
5071 * @param array $comment_ids Comment ids in this batch.
5072 * @return array comment_ID => array of meta_key => list of values.
5073 */
5074 private function get_comment_meta_for_export($comment_ids) {
5075 global $wpdb;
5076
5077 $comment_ids = array_values(array_filter(array_map('intval', (array) $comment_ids)));
5078
5079 if (empty($comment_ids)) {
5080 return array();
5081 }
5082
5083 $placeholders = implode(', ', array_fill(0, count($comment_ids), '%d'));
5084
5085 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
5086 $rows = $wpdb->get_results(
5087 $wpdb->prepare(
5088 "SELECT comment_id, meta_key, meta_value FROM $wpdb->commentmeta WHERE comment_id IN ($placeholders)",
5089 $comment_ids
5090 ),
5091 ARRAY_A
5092 );
5093
5094 // delete_comments_for_target() removes these rows too. Exporting a
5095 // batch with its metadata silently missing, and counting it as backed
5096 // up, loses exactly what the file exists to preserve.
5097 $this->assert_query_succeeded();
5098
5099 $out = array();
5100 foreach ((array) $rows as $row) {
5101 $id = (int) $row['comment_id'];
5102
5103 if (!isset($out[$id])) {
5104 $out[$id] = array();
5105 }
5106
5107 // A key can legitimately repeat, so values are lists.
5108 $out[$id][$row['meta_key']][] = $row['meta_value'];
5109 }
5110
5111 return $out;
5112 }
5113
5114 /**
5115 * Core comment columns carried by the export.
5116 *
5117 * @return array Column names, in wp_comments order.
5118 */
5119 private function get_exported_comment_columns() {
5120 return array(
5121 'comment_ID',
5122 'comment_post_ID',
5123 'comment_author',
5124 'comment_author_email',
5125 'comment_author_url',
5126 'comment_author_IP',
5127 'comment_date',
5128 'comment_date_gmt',
5129 'comment_content',
5130 'comment_karma',
5131 'comment_approved',
5132 'comment_agent',
5133 'comment_type',
5134 'comment_parent',
5135 'user_id',
5136 );
5137 }
5138
5139 /**
5140 * Export columns written without the spreadsheet guard.
5141 *
5142 * Deliberately the inverse of a list of "free-text" columns to escape.
5143 * That is how comment_author_IP came to be written raw: an allowlist
5144 * protects only what somebody remembered to put in it, and every column
5145 * this export gains later is unprotected until somebody remembers again.
5146 * Naming the exceptions instead makes a new column safe by default, and
5147 * wrong only in the harmless direction.
5148 *
5149 * The exceptions are the columns wp_comments types as integers and
5150 * datetimes. MySQL will not store a leading =, +, - or @ in any of them, so
5151 * the guard could never fire; leaving them raw keeps a restore that does
5152 * not call csv_unescape() byte-exact. Every other column is a varchar or
5153 * text field holding whatever wrote it - including comment_author_IP, which
5154 * is only ever an address by convention.
5155 *
5156 * @since 2.9.1
5157 * @return array Column names to write unescaped.
5158 */
5159 private function get_unescaped_export_columns() {
5160 return array(
5161 'comment_ID',
5162 'comment_post_ID',
5163 'comment_date',
5164 'comment_date_gmt',
5165 'comment_karma',
5166 'comment_parent',
5167 'user_id',
5168 );
5169 }
5170
5171 /**
5172 * Write the comments a delete request matches to an open stream as CSV.
5173 *
5174 * Rows are fetched in batches and keyed off the last id seen rather than
5175 * an offset: the sites that reach for this tool are the ones holding
5176 * hundreds of thousands of spam rows, and loading that into memory to
5177 * "back it up" would take the site down instead.
5178 *
5179 * @param resource $handle Open, writable stream.
5180 * @param array $_args Form data.
5181 * @param bool $is_network_ctx Whether this runs in a network admin context.
5182 * @return int Number of rows written.
5183 */
5184 public function stream_comments_csv($handle, $_args = array(), $is_network_ctx = false) {
5185 $formArray = $this->get_form_array_escaped($_args);
5186
5187 // Every core comment column, not a display-friendly subset. This file
5188 // is offered as a backup taken before an irreversible delete, so it has
5189 // to be able to reconstruct the rows: comment_parent carries thread
5190 // structure and user_id carries registered-user attribution, and
5191 // without them a "backup" restores a flat list of anonymous comments.
5192 //
5193 // blog_id leads because comment and post ids are site-local and collide
5194 // across subsites - a network export without it cannot be attributed
5195 // back to a site once the comments are gone.
5196 // A fresh export starts a fresh ceiling; a delete must never be
5197 // constrained by what some earlier export in this request reached.
5198 $this->last_export_ceilings = array();
5199
5200 $columns = array_merge(
5201 array('blog_id'),
5202 $this->get_exported_comment_columns(),
5203 // delete_comments_for_target() removes the matching commentmeta
5204 // rows as well, so a file that omits them cannot restore
5205 // everything the operation destroyed - ratings, moderation state
5206 // and any plugin's own fields. JSON keeps it to one column while
5207 // staying machine-readable.
5208 array('comment_meta', 'post_title')
5209 );
5210
5211 if (!$this->csv_write($handle, $this->csv_line($columns))) {
5212 throw new RuntimeException('Could not write the comment export.');
5213 }
5214
5215 // A backup that omits the subsites the delete is about to empty is not
5216 // a backup, so the export follows the same site routing.
5217 if ($this->is_network_delete($is_network_ctx)) {
5218 $written = 0;
5219 $blog_ids = $this->get_selected_delete_blog_ids($formArray, $is_network_ctx);
5220
5221 foreach ($blog_ids as $blog_id) {
5222 switch_to_blog($blog_id);
5223
5224 // The same per-site check delete_comments_settings() makes
5225 // before emptying a subsite. Without it the two disagree, and
5226 // the direction they disagree in is the bad one: a user who
5227 // holds manage_network_plugins but not manage_options on this
5228 // subsite cannot delete its comments, yet could export them —
5229 // author names, email addresses and IPs — straight out of a
5230 // site they have no rights on.
5231 //
5232 // Skipping here also keeps the pair consistent: a site the
5233 // export passed over records no ceiling, and a target with no
5234 // ceiling deletes nothing.
5235 //
5236 // No WP-CLI carve-out, deliberately, because the delete loop
5237 // has none either: both branches only run when the caller
5238 // passed a network context, and the CLI never does. A carve-out
5239 // here would only be a hole the delete does not have.
5240 if (!$this->can_manage_current_site()) {
5241 restore_current_blog();
5242 continue;
5243 }
5244
5245 $written += $this->stream_comments_for_current_site($handle, $formArray, $is_network_ctx);
5246 restore_current_blog();
5247 }
5248
5249 return $written;
5250 }
5251
5252 return $this->stream_comments_for_current_site($handle, $formArray, $is_network_ctx);
5253 }
5254
5255 /**
5256 * Write the matching comments on whichever site is current.
5257 *
5258 * @param resource $handle Open, writable stream.
5259 * @param array $formArray Parsed delete payload.
5260 * @param bool $is_network_ctx Whether this is a network admin request.
5261 * @return int Rows written.
5262 */
5263 private function stream_comments_for_current_site($handle, $formArray, $is_network_ctx) {
5264 global $wpdb;
5265
5266 $targets = $this->get_delete_targets($formArray, $is_network_ctx);
5267 $batch_size = 500;
5268 $written = 0;
5269 $blog_id = (int) get_current_blog_id();
5270
5271 // Resolved once: the innermost loop below runs per comment, and the
5272 // sites that reach for this tool hold hundreds of thousands of them.
5273 $raw_columns = $this->get_unescaped_export_columns();
5274
5275 foreach ($targets as $target) {
5276 $last_id = 0;
5277 $ceiling = 0;
5278
5279 do {
5280 $select = "SELECT comments.* FROM $wpdb->comments comments";
5281 $sql = $this->build_target_query(
5282 $select,
5283 $target,
5284 ' AND comments.comment_ID > %d ORDER BY comments.comment_ID ASC LIMIT ' . (int) $batch_size,
5285 array($last_id)
5286 );
5287
5288 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
5289 $rows = $wpdb->get_results($sql, ARRAY_A);
5290
5291 // Before the empty check, not after: a failed read is
5292 // indistinguishable from the end of the batches, and treating
5293 // it as the end produces a short or header-only file that the
5294 // CLI then reports as a complete backup and deletes against.
5295 $this->assert_query_succeeded();
5296
5297 if (empty($rows)) {
5298 break;
5299 }
5300
5301 $meta = $this->get_comment_meta_for_export(wp_list_pluck($rows, 'comment_ID'));
5302
5303 foreach ($rows as $row) {
5304 $last_id = (int) $row['comment_ID'];
5305
5306 $title = get_the_title($row['comment_post_ID']);
5307
5308 $line = array(get_current_blog_id());
5309
5310 foreach ($this->get_exported_comment_columns() as $column) {
5311 $value = isset($row[$column]) ? $row[$column] : '';
5312 // Guarded unless the column is one of the few that
5313 // cannot carry a payload - see
5314 // get_unescaped_export_columns() for why the test runs
5315 // this way round.
5316 $line[] = in_array($column, $raw_columns, true)
5317 ? $value
5318 : $this->csv_escape($value);
5319 }
5320
5321 $comment_meta = isset($meta[(int) $row['comment_ID']]) ? $meta[(int) $row['comment_ID']] : array();
5322 $line[] = $this->csv_escape(empty($comment_meta) ? '' : wp_json_encode($comment_meta));
5323 $line[] = $this->csv_escape($title);
5324
5325 $ok = $this->csv_write($handle, $this->csv_line($line));
5326
5327 // A disk that fills up mid-export must not produce a
5328 // truncated file the caller then treats as a complete
5329 // backup and deletes against.
5330 if (!$ok) {
5331 throw new RuntimeException('Could not write the comment export.');
5332 }
5333
5334 $written++;
5335
5336 if ($last_id > $ceiling) {
5337 $ceiling = $last_id;
5338 }
5339 }
5340 } while (count($rows) === $batch_size);
5341
5342 // comment_ID is AUTO_INCREMENT, so anything inserted after this
5343 // target finished sorts above its ceiling, and capping its delete
5344 // there keeps it to rows the file contains.
5345 //
5346 // Per target, not per blog. Targets stream one after another, so a
5347 // blog-wide maximum lets a later target raise an earlier one's cap:
5348 // finish exporting posts at id 1000, a new post comment arrives as
5349 // 1001 unexported, then a page comment at 1002 is exported and
5350 // lifts the blog ceiling to 1002 — and the delete takes 1001 with
5351 // it. Each target is bounded by what that target actually read.
5352 $signature = $this->target_signature($target);
5353 $current = isset($this->last_export_ceilings[$blog_id][$signature])
5354 ? $this->last_export_ceilings[$blog_id][$signature]
5355 : 0;
5356 $this->last_export_ceilings[$blog_id][$signature] = max($current, $ceiling);
5357 }
5358
5359 return $written;
5360 }
5361
5362 /**
5363 * CSV for the comments a delete request matches, as a string.
5364 *
5365 * Convenience wrapper around stream_comments_csv() for WP-CLI and tests.
5366 * The download handler streams instead of calling this.
5367 *
5368 * @param array $_args Form data.
5369 * @param bool $is_network_ctx Whether this runs in a network admin context.
5370 * @return string CSV payload.
5371 */
5372 public function export_comments_csv($_args = array(), $is_network_ctx = false) {
5373 $handle = fopen('php://temp', 'r+');
5374 if (false === $handle) {
5375 return '';
5376 }
5377
5378 $this->stream_comments_csv($handle, $_args, $is_network_ctx);
5379
5380 rewind($handle);
5381 $csv = stream_get_contents($handle);
5382 fclose($handle);
5383
5384 return $csv;
5385 }
5386
5387 /**
5388 * AJAX: how many comments would this delete remove?
5389 */
5390 public function preview_delete_comments() {
5391 $nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');
5392
5393 if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
5394 wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
5395 }
5396
5397 $is_network_ctx = $this->is_network_admin_ajax_context();
5398
5399 if (!current_user_can($this->get_required_delete_cap($is_network_ctx))) {
5400 wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
5401 }
5402
5403 $preview = $this->count_comments_for_delete(array(), $is_network_ctx, self::DELETE_PREVIEW_SAMPLE_SIZE);
5404
5405 wp_send_json_success($preview);
5406 }
5407
5408 /**
5409 * AJAX: download the matching comments as CSV before deleting them.
5410 */
5411 public function export_comments_download() {
5412 $nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');
5413
5414 if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
5415 wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
5416 }
5417
5418 $is_network_ctx = $this->is_network_admin_ajax_context();
5419
5420 if (!current_user_can($this->get_required_delete_cap($is_network_ctx))) {
5421 wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
5422 }
5423
5424 nocache_headers();
5425 header('Content-Type: text/csv; charset=' . get_option('blog_charset'));
5426 header('Content-Disposition: attachment; filename=disable-comments-export-' . gmdate('Y-m-d-His') . '.csv');
5427
5428 $handle = fopen('php://output', 'w');
5429 if (false !== $handle) {
5430 $this->stream_comments_csv($handle, array(), $is_network_ctx);
5431 fclose($handle);
5432 }
5433
5434 exit;
5435 }
5436
5437 /**
5438 * Delete the rows one target matches, meta first.
5439 *
5440 * @param array $target A target from get_delete_targets().
5441 */
5442 private function recalculate_comment_counts($post_type = null) {
5443 global $wpdb;
5444
5445 // Only reached when an export ceiling was in effect. The usual path
5446 // sets comment_count to zero because the delete emptied the target
5447 // outright; a ceiling deliberately leaves the comments that arrived
5448 // after the export behind, and zeroing anyway hides them from every
5449 // count-based display on the site.
5450 //
5451 // Counts approved comments only, which is what core's own
5452 // wp_update_comment_count_now() means by comment_count.
5453 $count = "(SELECT COUNT(*) FROM $wpdb->comments comments WHERE comments.comment_post_ID = posts.ID AND comments.comment_approved = '1')";
5454
5455 if (null === $post_type) {
5456 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
5457 $wpdb->query("UPDATE $wpdb->posts posts SET comment_count = $count");
5458
5459 return;
5460 }
5461
5462 // Deliberately without the post_author != 0 filter the zeroing query
5463 // alongside this one carries. That filter decides which posts the
5464 // delete bothers to blank; this query is fixing a number that is now
5465 // wrong, and a post with no author needs an accurate count just as
5466 // much as any other. Inheriting it left authorless posts reporting a
5467 // count for comments that are gone.
5468 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
5469 $wpdb->query($wpdb->prepare("UPDATE $wpdb->posts posts SET comment_count = $count WHERE posts.post_type = %s", $post_type));
5470 }
5471
5472 private function delete_comments_for_target($target, $max_id = null) {
5473 global $wpdb;
5474
5475 // An export, if one ran, has already read every row it is going to.
5476 // A comment arriving between that last read and this delete is not in
5477 // the file, so it must survive: comment_ID is AUTO_INCREMENT, which
5478 // makes a ceiling on the id an exact description of "what the backup
5479 // saw". Without an export there is no ceiling and this is a no-op.
5480 //
5481 // The window this does NOT close: an older comment that changes into
5482 // the matching set during it — an existing comment marked as spam
5483 // after the export read past it — keeps its low id and is still
5484 // removed without being in the file. Closing that needs a lock or a
5485 // transaction across both operations, which is a bigger change than
5486 // this one and not obviously worth it for a maintenance tool.
5487 $ceiling_sql = '';
5488 $ceiling_params = array();
5489
5490 if (null !== $max_id) {
5491 $ceiling_sql = ' AND comments.comment_ID <= %d';
5492 $ceiling_params = array((int) $max_id);
5493 }
5494
5495 // Meta first: once the comments are gone, the join that identifies
5496 // their meta rows no longer matches anything.
5497 $meta_sql = $this->build_target_query(
5498 "DELETE cmeta FROM $wpdb->commentmeta cmeta INNER JOIN $wpdb->comments comments ON cmeta.comment_id=comments.comment_ID",
5499 $target,
5500 $ceiling_sql,
5501 $ceiling_params
5502 );
5503 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
5504 $wpdb->query($meta_sql);
5505
5506 $sql = $this->build_target_query("DELETE comments FROM $wpdb->comments comments", $target, $ceiling_sql, $ceiling_params);
5507 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
5508 $wpdb->query($sql);
5509 }
5510
5511 private function delete_comments($_args, $is_network_ctx = false, $ceilings = null) {
5512 global $wpdb;
5513 global $deletedPostTypeNames;
5514
5515 // When an export ran first, this delete is the second half of a
5516 // "back it up, then remove it" pair, and it must not remove anything
5517 // the file does not contain. A blog absent from the map exported
5518 // nothing, so its ceiling is zero and it deletes nothing.
5519 //
5520 // Null means no export ran: an ordinary delete, uncapped.
5521 $blog_ceilings = null;
5522 if (is_array($ceilings)) {
5523 $blog_id = (int) get_current_blog_id();
5524 $blog_ceilings = isset($ceilings[$blog_id]) ? (array) $ceilings[$blog_id] : array();
5525 }
5526
5527 $formArray = $this->get_form_array_escaped($_args);
5528 $targets = $this->get_delete_targets($formArray, $is_network_ctx);
5529
5530 if (empty($targets)) {
5531 return '';
5532 }
5533
5534 $mode = $formArray['delete_mode'];
5535 $types = $this->get_all_post_types($is_network_ctx);
5536
5537 foreach ($targets as $target) {
5538 $max_id = null;
5539 if (null !== $blog_ceilings) {
5540 $signature = $this->target_signature($target);
5541 // A target the export never recorded exported nothing, so it
5542 // deletes nothing.
5543 $max_id = isset($blog_ceilings[$signature]) ? (int) $blog_ceilings[$signature] : 0;
5544 }
5545
5546 $this->delete_comments_for_target($target, $max_id);
5547
5548 if ('selected_delete_types' === $mode) {
5549 if (null === $max_id) {
5550 // Nothing was spared, so every post of this type is empty.
5551 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
5552 $wpdb->query($wpdb->prepare("UPDATE $wpdb->posts SET comment_count = 0 WHERE post_author != 0 AND post_type = %s", $target['post_type']));
5553 } else {
5554 $this->recalculate_comment_counts($target['post_type']);
5555 }
5556 }
5557
5558 if ('selected_delete_types' === $mode || 'selected_delete_comment_types' === $mode) {
5559 $deletedPostTypeNames[] = $target['label'];
5560 }
5561 }
5562
5563 if ('delete_everywhere' === $mode) {
5564 // $blog_ceilings, not $max_id: that one is per target and scoped to
5565 // the loop above, and reading it out here would just be whatever
5566 // the last iteration happened to leave behind. The question here is
5567 // only whether an export ran at all.
5568 if (null === $blog_ceilings) {
5569 // Update comment counts
5570 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
5571 $wpdb->query("UPDATE $wpdb->posts SET comment_count = 0");
5572 } else {
5573 $this->recalculate_comment_counts();
5574 }
5575 } elseif ('selected_delete_comment_types' === $mode) {
5576 // Update comment_count on post_types
5577 foreach ($types as $key => $value) {
5578 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
5579 $comment_count = $wpdb->get_var($wpdb->prepare("SELECT COUNT(comments.comment_ID) FROM $wpdb->comments comments INNER JOIN $wpdb->posts posts ON comments.comment_post_ID=posts.ID WHERE posts.post_type = %s", $key));
5580 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
5581 $wpdb->query($wpdb->prepare("UPDATE $wpdb->posts SET comment_count = %d WHERE post_author != 0 AND post_type = %s", $comment_count, $key));
5582 }
5583 }
5584
5585 $this->optimize_table($wpdb->commentmeta);
5586 $this->optimize_table($wpdb->comments);
5587
5588 $log = ('delete_spam' === $mode)
5589 ? __('All spam comments have been deleted.', 'disable-comments')
5590 : __('All comments have been deleted', 'disable-comments');
5591
5592 delete_transient('wc_count_comments');
5593 return $log;
5594 }
5595
5596 /**
5597 * Total rows in the comments table.
5598 *
5599 * @return int
5600 */
5601 private function count_all_comments() {
5602 global $wpdb;
5603
5604 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
5605 return (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments");
5606 }
5607
5608 private function discussion_settings_allowed() {
5609 if (defined('DISABLE_COMMENTS_ALLOW_DISCUSSION_SETTINGS') && DISABLE_COMMENTS_ALLOW_DISCUSSION_SETTINGS == true) {
5610 return true;
5611 }
5612 }
5613
5614 public function single_site_deactivate() {
5615 // for single sites, delete the options upon deactivation, not uninstall.
5616 delete_option('disable_comments_options');
5617 $this->delete_blocked_stats_options();
5618
5619 // The settings this trigger was recorded against are gone, so a
5620 // reactivation would greet the user with "we just cleared N comments"
5621 // about a delete belonging to the previous configuration.
5622 //
5623 // The per-user dismissal deliberately does not go with it: somebody
5624 // who said "don't ask again" must not be asked again because an admin
5625 // toggled the plugin off and on.
5626 delete_option(self::REVIEW_TRIGGER_OPTION);
5627 }
5628
5629 /**
5630 * Remove every option the blocked-attempt counters created.
5631 *
5632 * These are autoloaded and per-site. Left behind they are both stale
5633 * plugin data and, on a reinstall, someone else's counts presented as
5634 * current telemetry.
5635 *
5636 * @return void
5637 */
5638 public function delete_blocked_stats_options() {
5639 foreach (array_keys($this->get_blocked_vectors()) as $vector) {
5640 delete_option($this->get_blocked_vector_option($vector));
5641 }
5642
5643 delete_option(self::BLOCKED_SINCE_OPTION);
5644 }
5645
5646 /**
5647 * We need fresh data in every call. Called after switching to blog in loop.
5648 *
5649 * @return int The number of comments.
5650 */
5651 protected function __get_comment_count() {
5652 global $wpdb;
5653
5654 // Exclude allowed comment types from the count since they cannot be deleted
5655 // and should not be displayed in the "Total Comments" count in the Delete Comments tab
5656 $allowed_types = $this->get_allowed_comment_types();
5657
5658 if (empty($allowed_types)) {
5659 // No allowed types, count all comments
5660 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
5661 return $wpdb->get_var("SELECT COUNT(comment_id) FROM $wpdb->comments");
5662 }
5663
5664 // Build exclusion query for allowed comment types
5665 $placeholders = implode(', ', array_fill(0, count($allowed_types), '%s'));
5666 $query = $wpdb->prepare(
5667 "SELECT COUNT(comment_id) FROM $wpdb->comments WHERE comment_type NOT IN ($placeholders)",
5668 $allowed_types
5669 );
5670
5671 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
5672 return $wpdb->get_var($query);
5673 }
5674
5675 /**
5676 * Optimize a given table in the WordPress database.
5677 *
5678 * @param string $table_name The name of the table to optimize.
5679 */
5680 protected function optimize_table($table_name) {
5681 global $wpdb;
5682
5683 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
5684 return $wpdb->query("OPTIMIZE TABLE " . esc_sql($table_name));
5685 }
5686
5687 /**
5688 * Truncate a given table in the WordPress database.
5689 *
5690 * @param string $table_name The name of the table to truncate.
5691 */
5692 protected function truncate_table($table_name) {
5693 global $wpdb;
5694
5695 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
5696 return $wpdb->query("TRUNCATE TABLE " . esc_sql($table_name));
5697 }
5698
5699 /**
5700 * Get the current site-wide comment status as a descriptive string.
5701 *
5702 * This function analyzes the current Disable Comments plugin configuration
5703 * and returns a string describing which content types have comments disabled.
5704 *
5705 * @return string The current comment status:
5706 * - 'all' if comments are disabled site-wide for all content types
5707 * - 'posts' if comments are disabled only for posts
5708 * - 'pages' if comments are disabled only for pages
5709 * - 'posts,pages' if comments are disabled for both posts and pages
5710 * - 'custom_type_name' for other specific content types
5711 * - 'multiple' if multiple specific types are disabled (not all)
5712 * - 'none' if comments are not disabled anywhere
5713 *
5714 * @since 2.5.2
5715 */
5716 public function get_current_comment_status() {
5717 try {
5718 // Handle case where plugin is not properly initialized
5719 if (empty($this->options)) {
5720 return 'none';
5721 }
5722
5723 // Check if comments are disabled everywhere
5724 if ($this->is_remove_everywhere()) {
5725 return 'all';
5726 }
5727
5728 // Get disabled post types. Reporting-only, so unregistered slugs left
5729 // behind by a deactivated CPT plugin are excluded — they would
5730 // otherwise be summarised as though the type still existed.
5731 $disabled_post_types = $this->get_disabled_post_types_registered();
5732
5733 // If no post types are disabled, comments are enabled everywhere
5734 if (empty($disabled_post_types)) {
5735 return 'none';
5736 }
5737
5738 // Get all available post types that support comments
5739 $all_post_types = $this->get_all_post_types();
5740 $all_post_type_keys = array_keys($all_post_types);
5741
5742 // Check if all available post types are disabled
5743 if (count($disabled_post_types) >= count($all_post_type_keys)) {
5744 $missing_types = array_diff($all_post_type_keys, $disabled_post_types);
5745 if (empty($missing_types)) {
5746 return 'all';
5747 }
5748 }
5749
5750 // Handle specific common cases
5751 if (count($disabled_post_types) === 1) {
5752 $disabled_type = $disabled_post_types[0];
5753
5754 // Return the specific post type name for single disabled types
5755 switch ($disabled_type) {
5756 case 'post':
5757 return 'posts';
5758 case 'page':
5759 return 'pages';
5760 default:
5761 // For custom post types, return the post type slug
5762 return $disabled_type;
5763 }
5764 }
5765
5766 // Handle multiple specific post types
5767 if (count($disabled_post_types) === 2 &&
5768 in_array('post', $disabled_post_types) &&
5769 in_array('page', $disabled_post_types)) {
5770 return 'posts,pages';
5771 }
5772
5773 // For other combinations, return 'multiple' to indicate partial disabling
5774 return 'multiple';
5775 } catch (Exception $e) {
5776 // Error handling - return safe default
5777 if (defined('WP_DEBUG') && WP_DEBUG) {
5778 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging for WP_DEBUG mode
5779 error_log('Disable Comments: Error in get_current_comment_status() - ' . $e->getMessage());
5780 }
5781 return 'none';
5782 }
5783 }
5784
5785 /**
5786 * Get detailed comment status information including API restrictions.
5787 *
5788 * This function provides comprehensive information about comment restrictions
5789 * including post type restrictions, API-level restrictions, network settings,
5790 * role exclusions, and comment counts.
5791 *
5792 * @return array Associative array with detailed status information:
5793 * - 'status' => Main status (same as get_current_comment_status())
5794 * - 'disabled_post_types' => Array of disabled post type slugs
5795 * - 'disabled_post_type_labels' => Array of disabled post type labels
5796 * - 'remove_everywhere' => Boolean indicating global disable
5797 * - 'xmlrpc_disabled' => Boolean indicating XML-RPC comments disabled
5798 * - 'rest_api_disabled' => Boolean indicating REST API comments disabled
5799 * - 'total_post_types' => Total number of available post types
5800 * - 'is_configured' => Boolean indicating if plugin is configured
5801 * - 'total_comments' => Total number of comments in database
5802 * - 'network_active' => Boolean indicating if plugin is network activated
5803 * - 'sitewide_settings' => Site-wide settings status
5804 * - 'role_exclusion_enabled' => Boolean indicating if role exclusions are enabled
5805 * - 'excluded_roles' => Array of excluded role slugs
5806 * - 'excluded_role_labels' => Array of human-readable excluded role names
5807 *
5808 * @since 2.5.2
5809 */
5810 public function get_detailed_comment_status() {
5811 try {
5812 $status = $this->get_current_comment_status();
5813 $disabled_post_types = $this->get_disabled_post_types_registered();
5814 $all_post_types = $this->get_all_post_types();
5815
5816 // Get human-readable labels for disabled post types
5817 $disabled_labels = array();
5818 foreach ($disabled_post_types as $post_type) {
5819 if (isset($all_post_types[$post_type])) {
5820 $disabled_labels[] = $all_post_types[$post_type]->labels->name;
5821 } else {
5822 // Fallback for custom post types not in the main list
5823 $post_type_obj = get_post_type_object($post_type);
5824 $disabled_labels[] = $post_type_obj ? $post_type_obj->labels->name : $post_type;
5825 }
5826 }
5827
5828 // Get total comments count
5829 $total_comments = $this->get_all_comments_number();
5830
5831 // Determine site-wide settings status
5832 $sitewide_settings = 'not_applicable';
5833 if ($this->networkactive) {
5834 $sitewide_settings = isset($this->options['sitewide_settings']) && $this->options['sitewide_settings'] ?
5835 'enabled' : 'disabled';
5836 }
5837
5838 // Process role-based exclusion information
5839 $role_exclusion_enabled = isset($this->options['enable_exclude_by_role']) && $this->options['enable_exclude_by_role'];
5840 $excluded_roles = isset($this->options['exclude_by_role']) ? $this->options['exclude_by_role'] : array();
5841
5842 // Get human-readable role names
5843 $excluded_role_labels = array();
5844 if ($role_exclusion_enabled && !empty($excluded_roles)) {
5845 $editable_roles = get_editable_roles();
5846
5847 foreach ($excluded_roles as $role) {
5848 if ($role === 'logged-out-users') {
5849 $excluded_role_labels[] = __('Logged out users', 'disable-comments');
5850 } elseif (isset($editable_roles[$role])) {
5851 $excluded_role_labels[] = translate_user_role($editable_roles[$role]['name']);
5852 } else {
5853 $excluded_role_labels[] = $role;
5854 }
5855 }
5856 }
5857
5858 return array(
5859 'status' => $status,
5860 'disabled_post_types' => $disabled_post_types,
5861 'disabled_post_type_labels' => $disabled_labels,
5862 // Same vocabulary as the get-status ability, deliberately: the
5863 // two describe one site, and a Site Health panel disagreeing
5864 // with the API about it is worse than either being silent.
5865 // Counts follow get_conditional_rules(), so a rule stored while
5866 // the feature is off reports as zero - nothing is enforcing it.
5867 'conditional_rules_enabled' => $this->has_conditional_rules(),
5868 'conditional_rules_count' => count($this->get_conditional_rules()),
5869 'auto_close_days' => $this->get_auto_close_days(),
5870 'remove_everywhere' => $this->is_remove_everywhere(),
5871 'xmlrpc_disabled' => !empty($this->options['remove_xmlrpc_comments']),
5872 'rest_api_disabled' => !empty($this->options['remove_rest_API_comments']),
5873 'show_existing_comments' => !empty($this->options['show_existing_comments']),
5874 'total_post_types' => count($all_post_types),
5875 'is_configured' => $this->is_configured(),
5876 'total_comments' => $total_comments,
5877 'network_active' => $this->networkactive,
5878 'sitewide_settings' => $sitewide_settings,
5879 'role_exclusion_enabled' => $role_exclusion_enabled,
5880 'excluded_roles' => $excluded_roles,
5881 'excluded_role_labels' => $excluded_role_labels
5882 );
5883 } catch (Exception $e) {
5884 // Error handling - return safe defaults
5885 if (defined('WP_DEBUG') && WP_DEBUG) {
5886 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging for WP_DEBUG mode
5887 error_log('Disable Comments: Error in get_detailed_comment_status() - ' . $e->getMessage());
5888 }
5889 return array(
5890 'status' => 'none',
5891 'disabled_post_types' => array(),
5892 'disabled_post_type_labels' => array(),
5893 'conditional_rules_enabled' => false,
5894 'conditional_rules_count' => 0,
5895 'auto_close_days' => 0,
5896 'remove_everywhere' => false,
5897 'xmlrpc_disabled' => false,
5898 'rest_api_disabled' => false,
5899 'show_existing_comments' => false,
5900 'total_post_types' => 0,
5901 'is_configured' => false,
5902 'total_comments' => 0,
5903 'network_active' => false,
5904 'sitewide_settings' => 'not_applicable',
5905 'role_exclusion_enabled' => false,
5906 'excluded_roles' => array(),
5907 'excluded_role_labels' => array()
5908 );
5909 }
5910 }
5911 /**
5912 * Add Disable Comments information to WordPress Site Health Info panel.
5913 *
5914 * This method integrates the plugin's status information into WordPress's
5915 * built-in Site Health system for easy debugging and site overview.
5916 *
5917 * @param array $debug_info The debug information array.
5918 * @return array Modified debug information array.
5919 *
5920 * @since 2.5.2
5921 */
5922 public function add_site_health_info($debug_info) {
5923 $data = $this->get_detailed_comment_status();
5924
5925 // Create the main status description
5926 $status_descriptions = array(
5927 'all' => __('Comments are disabled site-wide for all content types', 'disable-comments'),
5928 'posts' => __('Comments are disabled only for blog posts', 'disable-comments'),
5929 'pages' => __('Comments are disabled only for pages', 'disable-comments'),
5930 'posts,pages' => __('Comments are disabled for both posts and pages', 'disable-comments'),
5931 'multiple' => __('Comments are disabled for multiple specific content types', 'disable-comments'),
5932 'none' => __('Comments are enabled everywhere', 'disable-comments'),
5933 );
5934
5935 // translators: %s: disabled post types.
5936 $other_status_description = sprintf(__('Comments are disabled for: %s', 'disable-comments'), $data['status']);
5937 $status_description = isset($status_descriptions[$data['status']]) ?
5938 $status_descriptions[$data['status']] :
5939 $other_status_description;
5940
5941 // Every sentence above describes the global and per-post-type settings
5942 // only. With rules live they are the starting point rather than the
5943 // answer - a site with nothing disabled globally still closes comments
5944 // on every post a rule matches, and this section read "Comments are
5945 // enabled everywhere" while that was happening.
5946 if (!empty($data['conditional_rules_enabled'])) {
5947 $status_description = sprintf(
5948 /* translators: %s: sentence describing the global and post-type settings. */
5949 __('%s. Conditional rules then close or reopen comments on individual posts', 'disable-comments'),
5950 $status_description
5951 );
5952 }
5953
5954 // Format site-wide settings value
5955 $sitewide_settings_labels = array(
5956 'enabled' => __('Enabled', 'disable-comments'),
5957 'disabled' => __('Disabled', 'disable-comments'),
5958 'not_applicable' => __('Not applicable', 'disable-comments'),
5959 );
5960
5961 // Build the fields array using data from get_detailed_comment_status()
5962 $fields = array(
5963 'status' => array(
5964 'label' => __('Comment Status', 'disable-comments'),
5965 'value' => $status_description,
5966 ),
5967 'plugin_configured' => array(
5968 'label' => __('Plugin Configured', 'disable-comments'),
5969 'value' => $data['is_configured'] ? __('Yes', 'disable-comments') : __('No', 'disable-comments'),
5970 ),
5971 'total_comments' => array(
5972 'label' => __('Total Comments', 'disable-comments'),
5973 'value' => number_format_i18n($data['total_comments']),
5974 ),
5975 'global_disable' => array(
5976 'label' => __('Global Disable Active', 'disable-comments'),
5977 'value' => $data['remove_everywhere'] ? __('Yes', 'disable-comments') : __('No', 'disable-comments'),
5978 ),
5979 'disabled_post_type_count' => array(
5980 'label' => __('Disabled Post Types Count', 'disable-comments'),
5981 'value' => sprintf('%d of %d', count($data['disabled_post_types']), $data['total_post_types']),
5982 ),
5983 'disabled_post_types' => array(
5984 'label' => __('Disabled Post Types', 'disable-comments'),
5985 'value' => !empty($data['disabled_post_type_labels']) ?
5986 implode(', ', $data['disabled_post_type_labels']) :
5987 __('None', 'disable-comments'),
5988 ),
5989 'xmlrpc_comments' => array(
5990 'label' => __('XML-RPC Comments', 'disable-comments'),
5991 'value' => $data['xmlrpc_disabled'] ? __('Disabled', 'disable-comments') : __('Enabled', 'disable-comments'),
5992 ),
5993 'rest_api_comments' => array(
5994 'label' => __('REST API Comments', 'disable-comments'),
5995 'value' => $data['rest_api_disabled'] ? __('Disabled', 'disable-comments') : __('Enabled', 'disable-comments'),
5996 ),
5997 'show_existing_comments' => array(
5998 'label' => __('Show Existing Comments', 'disable-comments'),
5999 'value' => $data['show_existing_comments'] ? __('Yes', 'disable-comments') : __('No', 'disable-comments'),
6000 ),
6001 'network_active' => array(
6002 'label' => __('Network Active', 'disable-comments'),
6003 'value' => $data['network_active'] ? __('Yes', 'disable-comments') : __('No', 'disable-comments'),
6004 ),
6005 'sitewide_settings' => array(
6006 'label' => __('Site-wide Settings', 'disable-comments'),
6007 'value' => $sitewide_settings_labels[$data['sitewide_settings']],
6008 ),
6009 'role_exclusion_enabled' => array(
6010 'label' => __('Role-based Exclusions', 'disable-comments'),
6011 'value' => $data['role_exclusion_enabled'] ? __('Enabled', 'disable-comments') : __('Disabled', 'disable-comments'),
6012 ),
6013 'excluded_roles' => array(
6014 'label' => __('Excluded Roles', 'disable-comments'),
6015 'value' => !empty($data['excluded_role_labels']) ?
6016 implode(', ', $data['excluded_role_labels']) :
6017 __('None', 'disable-comments'),
6018 ),
6019 // Reported whether or not they are on. Somebody comparing two
6020 // sites, or reading a support ticket's Site Health paste, needs
6021 // "rules: off" said out loud rather than inferred from a missing
6022 // line.
6023 'conditional_rules' => array(
6024 'label' => __('Conditional Rules', 'disable-comments'),
6025 'value' => $data['conditional_rules_enabled'] ?
6026 __('Enabled', 'disable-comments') :
6027 __('Disabled', 'disable-comments'),
6028 ),
6029 'conditional_rules_count' => array(
6030 'label' => __('Conditional Rules Configured', 'disable-comments'),
6031 'value' => number_format_i18n($data['conditional_rules_count']),
6032 ),
6033 'auto_close_days' => array(
6034 'label' => __('Auto-close Comments After', 'disable-comments'),
6035 'value' => $data['auto_close_days'] > 0 ?
6036 sprintf(
6037 /* translators: %s: number of days. */
6038 _n('%s day', '%s days', $data['auto_close_days'], 'disable-comments'),
6039 number_format_i18n($data['auto_close_days'])
6040 ) :
6041 __('No age limit', 'disable-comments'),
6042 ),
6043 );
6044
6045 // Blocked attempts. Site Health is where an admin auditing a site
6046 // looks, and it is the one screen that shows the plugin is still
6047 // doing something rather than sitting idle.
6048 if ($this->blocked_stats_enabled()) {
6049 $blocked = $this->get_blocked_stats();
6050
6051 $fields['blocked_since'] = array(
6052 'label' => __('Counting Blocked Attempts Since', 'disable-comments'),
6053 'value' => date_i18n(get_option('date_format'), $blocked['since']),
6054 );
6055
6056 foreach ($this->get_blocked_vectors() as $vector => $label) {
6057 $fields['blocked_' . $vector] = array(
6058 // translators: %s: name of the blocked request type.
6059 'label' => sprintf(__('Blocked: %s', 'disable-comments'), $label),
6060 'value' => number_format_i18n($blocked['counts'][$vector]),
6061 );
6062 }
6063 }
6064
6065 // Add the section to Site Health
6066 $debug_info['disable-comments'] = array(
6067 'label' => __('Disable Comments', 'disable-comments'),
6068 'description' => __('Complete overview of comment disable settings and configuration.', 'disable-comments'),
6069 'fields' => $fields,
6070 );
6071
6072 return $debug_info;
6073 }
6074 }
6075
6076 Disable_Comments::get_instance();
6077