diff --git a/.ai/README.md b/.ai/README.md new file mode 100644 index 0000000..37b38c7 --- /dev/null +++ b/.ai/README.md @@ -0,0 +1,27 @@ +# .ai/ — Agent Context Directory + +This directory stores documentation and context generated or maintained by AI agents (Claude Code, etc.) during development work. + +## Purpose + +- **Deep-dive architecture docs** — detail too verbose for `CLAUDE.md` +- **Investigation notes** — written while exploring complex features +- **Feature context** — background and rationale for non-obvious design decisions + +This is NOT a replacement for code comments, git history, or `docs/`. It supplements `CLAUDE.md` with detail that would otherwise bloat it. + +> **Agents:** When you discover context worth preserving, write it here — not into `CLAUDE.md`. Reference the file from `CLAUDE.md` with a one-line pointer. + +## Structure + +```text +.ai/ + README.md # This file — directory overview + security/ # Vulnerability reports and fix guidance (excluded from git) +``` + +## Rules for Agents + +- **Write here, not in `CLAUDE.md`** — keep CLAUDE.md as a concise rules + pointers file +- **One file per topic** — don't append unrelated notes to an existing file +- **This directory is excluded from distribution zips** — `.distignore` covers `.ai/` recursively diff --git a/.distignore b/.distignore index 671a2c5..62f6234 100644 --- a/.distignore +++ b/.distignore @@ -19,6 +19,7 @@ composer.lock Gruntfile.js package.json package-lock.json +pnpm-lock.yaml prepros.config phpunit.xml phpunit.xml.dist @@ -38,3 +39,6 @@ src assets/img/card *.map assets/scss +.ai/ +.claude/ +CLAUDE.md diff --git a/.gitignore b/.gitignore index 0932451..9466465 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,7 @@ node_modules/ vendor/ .vscode/ .idea +.claude/worktrees/ package-lock.json pnpm-lock.yaml +.ai/security/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1a25a4f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,78 @@ +# Disable Comments — Plugin Development Guide + +WordPress plugin by WPDeveloper. Allows administrators to globally disable comments by post type, with multisite network support. + +- **WordPress.org:** +- **Current version:** 2.6.2 +- **Main class:** `Disable_Comments` (singleton) in `disable-comments.php` + +--- + +## Project Structure + +```text +disable-comments.php Main plugin file (~2000 lines), single class +includes/ + cli.php WP-CLI command definitions + class-plugin-usage-tracker.php +views/ + settings.php Main settings page shell + comments.php Tools/delete page shell + partials/ + _disable.php Disable-comments form (main settings form) + _delete.php Delete comments form + _sites.php Multisite sub-site list + _menu.php / _footer.php / _sidebar.php +assets/ + js/disable-comments-settings-scripts.js Settings page JS (role exclusion UI, AJAX calls) + js/disable-comments.js + css/ scss/ +tests/ + test-plugin.php PHPUnit tests (Brain/Monkey mocking) + bootstrap.php +``` + +--- + +## Key AJAX Handlers + +All three AJAX handlers are registered in `__construct()` (~line 49): + +| Action | Handler | Line | +| ------ | ------- | ---- | +| `disable_comments_save_settings` | `disable_comments_settings()` | ~1217 | +| `disable_comments_delete_comments` | `delete_comments_settings()` | ~1324 | +| `get_sub_sites` | `get_sub_sites()` | ~1157 | + +**Nonce:** All handlers verify nonce `disable_comments_save_settings`. The nonce is created in `admin_enqueue_scripts()` (~line 799) and exposed to JS as `disableCommentsObj._nonce`. + +**POST data parsing:** `get_form_array_escaped()` (~line 1202) reads `$_POST['data']` as a URL-encoded string, parses with `wp_parse_args()`, and sanitizes all values with `map_deep(..., 'sanitize_text_field')`. + +**Network admin flag:** `$formArray['is_network_admin']` comes from POST data and controls network-wide operations — always verify server-side capability before acting on it. + +--- + +## Development + +```bash +npm install # Install JS build deps +npm run build # Compile JS/CSS via Grunt + Babel +npm run release # Build + generate .pot + package release +``` + +```bash +composer install # Install PHP dev deps (Brain/Monkey for tests) +./vendor/bin/phpunit # Run tests +``` + +**Linting:** `phpcs.ruleset.xml` is configured for WordPress Coding Standards. + +--- + +## Architecture Notes + +- **Singleton pattern:** Always access via `Disable_Comments::get_instance()`. +- **CLI support:** `includes/cli.php` calls the same handler methods with `$_args` to bypass nonce (expected for WP-CLI context; nonce bypass is gated on `$this->is_CLI`). +- **Multisite vs single-site:** Plugin behaviour branches heavily on `$this->networkactive` (set in constructor) and `$this->sitewide_settings`. +- **Database queries:** Use `$wpdb->prepare()` throughout `delete_comments()`. Safe against SQL injection. +- **Input sanitization:** `get_form_array_escaped()` uses `wp_parse_args()` + `map_deep(sanitize_text_field)`. diff --git a/assets/js/disable-comments-settings-scripts.js b/assets/js/disable-comments-settings-scripts.js index 733d071..fa55a30 100644 --- a/assets/js/disable-comments-settings-scripts.js +++ b/assets/js/disable-comments-settings-scripts.js @@ -6,6 +6,9 @@ jQuery(document).ready(function ($) { var saveBtn = jQuery("#disableCommentSaveSettings button.button.button__success"); var deleteBtn = jQuery("#deleteCommentSettings button.button.button__delete"); var savedData; + var networkAjaxUrl = disableCommentsObj.is_network_admin === '1' + ? ajaxurl + (ajaxurl.indexOf('?') === -1 ? '?' : '&') + 'is_network_admin=1' + : ajaxurl; if(jQuery('.sites_list_wrapper').length){ var addSite = function($sites_list, site, type){ @@ -49,7 +52,7 @@ jQuery(document).ready(function ($) { var $pageSizeWrapper = $sites_list_wrapper.find('.page__size__wrapper'); var isPageLoaded = {}; var args = { - dataSource : ajaxurl, + dataSource : networkAjaxUrl, locator : 'data', pageSize : $pageSize.val() || 50, showPageNumbers : false, @@ -74,6 +77,12 @@ jQuery(document).ready(function ($) { }, }; }, + formatAjaxError: function(jqXHR) { + var msg = jqXHR.responseJSON && jqXHR.responseJSON.data && jqXHR.responseJSON.data.message + ? jqXHR.responseJSON.data.message + : __('Something went wrong!', 'disable-comments'); + Swal.fire({ icon: 'error', title: __('Oops...', 'disable-comments'), text: msg }); + }, callback : function(data, pagination) { var pageNumber = pagination.pageNumber; addSites($sites_list, data, type); @@ -296,7 +305,7 @@ jQuery(document).ready(function ($) { }; jQuery.ajax({ - url: ajaxurl, + url: networkAjaxUrl, type: "post", data: data, beforeSend: function () { @@ -316,14 +325,24 @@ jQuery(document).ready(function ($) { }); saveBtn.removeClass('form-dirty').prop('disabled', true); savedData = $form.serialize(); + } else { + saveBtn.html(__("Save Settings", "disable-comments")); + Swal.fire({ + icon: "error", + title: __("Oops...", "disable-comments"), + text: response.data && response.data.message ? response.data.message : __("Something went wrong!", "disable-comments"), + }); } }, - error: function () { - saveBtn.html("Save Settings"); + error: function (jqXHR) { + saveBtn.html(__("Save Settings", "disable-comments")); + var msg = jqXHR.responseJSON && jqXHR.responseJSON.data && jqXHR.responseJSON.data.message + ? jqXHR.responseJSON.data.message + : __("Something went wrong!", "disable-comments"); Swal.fire({ - type: "error", + icon: "error", title: __("Oops...", "disable-comments"), - text: __("Something went wrong!", "disable-comments"), + text: msg, }); }, }); @@ -360,13 +379,13 @@ jQuery(document).ready(function ($) { deleteBtn.html( '' + __("Deleting Comments..", "disable-comments") + '' ); - jQuery.post(ajaxurl, data, function (response) { + jQuery.post(networkAjaxUrl, data, function (response) { deleteBtn.html(__("Delete Comments", "disable-comments")); if (response.success) { Swal.fire({ icon: "success", title: __("Deleted", "disable-comments"), - html: response.data.message, + text: response.data.message, timer: 3000, showConfirmButton: false, }); @@ -374,10 +393,20 @@ jQuery(document).ready(function ($) { Swal.fire({ icon: "error", title: __("Oops...", "disable-comments"), - html: response.data.message, + text: response.data && response.data.message ? response.data.message : __("Something went wrong!", "disable-comments"), showConfirmButton: true, }); } + }).fail(function (jqXHR) { + deleteBtn.html(__("Delete Comments", "disable-comments")); + var msg = jqXHR.responseJSON && jqXHR.responseJSON.data && jqXHR.responseJSON.data.message + ? jqXHR.responseJSON.data.message + : __("Something went wrong!", "disable-comments"); + Swal.fire({ + icon: "error", + title: __("Oops...", "disable-comments"), + text: msg, + }); }); } }); @@ -432,7 +461,10 @@ jQuery(document).ready(function ($) { }).map(function(val, index){ return val.id; }); - var text = "" + _selectedOptions.join(", ") + ""; + var escapedOptions = _selectedOptions.map(function(label) { + return $('').text(label).html(); + }); + var text = "" + escapedOptions.join(", ") + ""; excludedRoles.html(sprintf(__("Comments are visible to %s and Logged out users.", "disable-comments"), text)); includedRoles.text(__("No comments will be visible to other roles.", "disable-comments")); } @@ -441,7 +473,10 @@ jQuery(document).ready(function ($) { var selectedOptionsLabels = selectedOptions.map(function(val, index){ return val.text; }); - var text = "" + selectedOptionsLabels.join(", ") + ""; + var escapedLabels = selectedOptionsLabels.map(function(label) { + return $('').text(label).html(); + }); + var text = "" + escapedLabels.join(", ") + ""; excludedRoles.html(sprintf(__("Comments are visible to %s.", "disable-comments"), text)); includedRoles.text(__("Other roles and logged out users won't see any comments.", "disable-comments")); } diff --git a/disable-comments.php b/disable-comments.php index 4d83185..759eb7d 100644 --- a/disable-comments.php +++ b/disable-comments.php @@ -116,10 +116,13 @@ function __construct() { } public function is_network_admin() { - $sanitized_referer = isset($_SERVER['HTTP_REFERER']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_REFERER'])) : ''; - if (is_network_admin() || !empty($sanitized_referer) && defined('DOING_AJAX') && DOING_AJAX && is_multisite() && preg_match('#^' . network_admin_url() . '#i', $sanitized_referer)) { + if (is_network_admin()) { return true; } + if (defined('DOING_AJAX') && DOING_AJAX) { + $is_network_admin_param = isset($_REQUEST['is_network_admin']) ? sanitize_text_field(wp_unslash($_REQUEST['is_network_admin'])) : ''; + return $is_network_admin_param === '1' && current_user_can('manage_network_plugins'); + } return false; } /** @@ -796,7 +799,8 @@ public function settings_page_assets($hook_suffix) { 'save_action' => 'disable_comments_save_settings', 'delete_action' => 'disable_comments_delete_comments', 'settings_URI' => $this->settings_page_url(), - '_nonce' => wp_create_nonce('disable_comments_save_settings') + '_nonce' => wp_create_nonce('disable_comments_save_settings'), + 'is_network_admin' => is_network_admin() ? '1' : '0' ) ); wp_set_script_translations('disable-comments-scripts', 'disable-comments'); @@ -829,7 +833,7 @@ public function discussion_notice() { } // translators: %s: disabled post types. - echo '

' . sprintf(esc_html__('Note: The Disable Comments 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)) . '

'; + echo '

' . wp_kses_post(sprintf(__('Note: The Disable Comments 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(__(', ', 'disable-comments'), $names_escaped))) . '

'; } } @@ -1159,7 +1163,12 @@ public function settings_page() { public function get_sub_sites() { $nonce = (isset($_REQUEST['nonce']) ? sanitize_text_field(wp_unslash($_REQUEST['nonce'])) : ''); if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) { - wp_send_json(['data' => [], 'totalNumber' => 0]); + wp_send_json_error(['message' => __('Invalid request. Please refresh the page and try again.', 'disable-comments')], 403); + } + + $required_cap = is_multisite() ? 'manage_network_plugins' : 'manage_options'; + if (!current_user_can($required_cap)) { + wp_send_json_error(['message' => __('Sorry, you are not allowed to access this resource.', 'disable-comments')], 403); } $_sub_sites = []; @@ -1216,17 +1225,31 @@ public function get_form_array_escaped($_args = array()) { public function disable_comments_settings($_args = array()) { $nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''); - if (($this->is_CLI && !empty($_args)) || wp_verify_nonce($nonce, 'disable_comments_save_settings')) { + + if (!$this->is_CLI) { + if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) { + wp_send_json_error(['message' => __('Nonce verification failed.', 'disable-comments')], 403); + } + } + + if (($this->is_CLI && !empty($_args)) || !$this->is_CLI) { $formArray = $this->get_form_array_escaped($_args); + $is_network_action = $this->is_CLI + ? (!empty($formArray['is_network_admin']) && $formArray['is_network_admin'] == '1') + : $this->is_network_admin(); + $required_cap = $is_network_action ? 'manage_network_plugins' : 'manage_options'; + if (!$this->is_CLI && !current_user_can($required_cap)) { + wp_send_json_error(['message' => __('Insufficient permissions.', 'disable-comments')], 403); + } - $old_options = $this->options; + $old_options = $this->is_CLI ? $this->options : ($is_network_action ? get_site_option('disable_comments_options', []) : $this->options); $this->options = []; if ($this->is_CLI) { $this->options = $old_options; } - $this->options['is_network_admin'] = isset($formArray['is_network_admin']) && $formArray['is_network_admin'] == '1' ? true : false; + $this->options['is_network_admin'] = $is_network_action; if (!empty($this->options['is_network_admin']) && function_exists('get_sites') && empty($formArray['sitewide_settings'])) { $formArray['disabled_sites'] = isset($formArray['disabled_sites']) ? $formArray['disabled_sites'] : []; @@ -1256,12 +1279,12 @@ public function disable_comments_settings($_args = array()) { $this->options['extra_post_types'] = array_diff($extra_post_types, array_keys($post_types)); // Make sure we don't double up builtins. } - if (isset($formArray['sitewide_settings'])) { + if ($is_network_action && isset($formArray['sitewide_settings'])) { update_site_option('disable_comments_sitewide_settings', $formArray['sitewide_settings']); } if (isset($formArray['disable_avatar'])) { - if ($this->is_network_admin()) { + if ($is_network_action) { if ($formArray['disable_avatar'] == '0' || $formArray['disable_avatar'] == '1') { $sites = get_sites([ 'number' => 0, @@ -1326,10 +1349,28 @@ public function delete_comments_settings($_args = array()) { $log = ''; $nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : ''); - if (($this->is_CLI && !empty($_args)) || wp_verify_nonce($nonce, 'disable_comments_save_settings')) { + if (!$this->is_CLI) { + if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) { + wp_send_json_error(['message' => __('Nonce verification failed.', 'disable-comments')], 403); + wp_die(); + } + } + + if (($this->is_CLI && !empty($_args)) || !$this->is_CLI) { $formArray = $this->get_form_array_escaped($_args); + $is_network_action = $this->is_CLI + ? (!empty($formArray['is_network_admin']) && $formArray['is_network_admin'] == '1') + : $this->is_network_admin(); + + if (!$this->is_CLI) { + $required_cap = $is_network_action ? 'manage_network_plugins' : 'manage_options'; + if (!current_user_can($required_cap)) { + wp_send_json_error(['message' => __('Insufficient permissions.', 'disable-comments')], 403); + wp_die(); + } + } - if (!empty($formArray['is_network_admin']) && function_exists('get_sites') && class_exists('WP_Site_Query')) { + if ($is_network_action && function_exists('get_sites') && class_exists('WP_Site_Query')) { $sites = get_sites([ 'number' => 0, 'fields' => 'ids', @@ -1337,6 +1378,9 @@ public function delete_comments_settings($_args = array()) { foreach ($sites as $blog_id) { // $formArray['disabled_sites'] ids don't include "site_" prefix. if (!empty($formArray['disabled_sites']) && !empty($formArray['disabled_sites']["site_$blog_id"])) { + if (!is_super_admin() && !is_user_member_of_blog(get_current_user_id(), $blog_id)) { + continue; // Skip sites the user doesn't belong to + } switch_to_blog($blog_id); $log = $this->delete_comments($_args); restore_current_blog(); @@ -1850,7 +1894,7 @@ public function add_site_health_info($debug_info) { ), 'disabled_post_type_count' => array( 'label' => __('Disabled Post Types Count', 'disable-comments'), - 'value' => sprintf('%d of %d', count($data['disabled_post_types']), $data['total_post_types']), + 'value' => sprintf(esc_html__('%1$d of %2$d', 'disable-comments'), count($data['disabled_post_types']), $data['total_post_types']), ), 'disabled_post_types' => array( 'label' => __('Disabled Post Types', 'disable-comments'),