| 1 |
<?php |
| 2 |
/** |
| 3 |
* The Abilities API section of Tools > System Info. |
| 4 |
* |
| 5 |
* Extracted from Charitable_Abilities in 1.8.18. That class's own docblock says |
| 6 |
* it "only wires the WordPress hooks, registers the category, and instantiates |
| 7 |
* the registrars"; it had also grown a 250-line diagnostics reporter, which is |
| 8 |
* this file. |
| 9 |
* |
| 10 |
* The goal of the section is to turn "the AI cannot see my campaigns" into a |
| 11 |
* one-paste diagnosis. |
| 12 |
* |
| 13 |
* THE RULE THIS FILE KEEPS RELEARNING: a line that can report a false problem |
| 14 |
* is worse than no line. Three separate measurements here refuse to run rather |
| 15 |
* than mislead, because each of them once reported a catastrophe on a |
| 16 |
* completely healthy site — see count_rest_visible(), which said "29 registered |
| 17 |
* but 0 visible" under WP-CLI, and application_passwords_status(), which said |
| 18 |
* "NOT available" on a site where WPVibe was authenticating perfectly. Anything |
| 19 |
* added here inherits that rule. |
| 20 |
* |
| 21 |
* @package Charitable/Classes/Charitable_Abilities_System_Info |
| 22 |
* @copyright Copyright (c) 2026, WP Charitable LLC |
| 23 |
* @license http://opensource.org/licenses/gpl-2.0.php GNU Public License |
| 24 |
* @since 1.8.13 |
| 25 |
* @version 1.8.13 |
| 26 |
*/ |
| 27 |
|
| 28 |
if ( ! defined( 'ABSPATH' ) ) { |
| 29 |
exit; |
| 30 |
} |
| 31 |
|
| 32 |
if ( ! class_exists( 'Charitable_Abilities_System_Info' ) ) : |
| 33 |
|
| 34 |
/** |
| 35 |
* Charitable_Abilities_System_Info |
| 36 |
* |
| 37 |
* @since 1.8.13 |
| 38 |
*/ |
| 39 |
class Charitable_Abilities_System_Info { |
| 40 |
|
| 41 |
/** |
| 42 |
* Single instance. |
| 43 |
* |
| 44 |
* @since 1.8.13 |
| 45 |
* @var Charitable_Abilities_System_Info|null |
| 46 |
*/ |
| 47 |
private static $instance = null; |
| 48 |
|
| 49 |
/** |
| 50 |
* Width the labels are padded to, so values line up in a pasted report. |
| 51 |
* |
| 52 |
* @since 1.8.13 |
| 53 |
* @var int |
| 54 |
*/ |
| 55 |
const LABEL_WIDTH = 26; |
| 56 |
|
| 57 |
/** |
| 58 |
* Returns and/or creates the single instance of this class. |
| 59 |
* |
| 60 |
* @since 1.8.13 |
| 61 |
* |
| 62 |
* @return Charitable_Abilities_System_Info |
| 63 |
*/ |
| 64 |
public static function get_instance() { |
| 65 |
if ( is_null( self::$instance ) ) { |
| 66 |
self::$instance = new self(); |
| 67 |
} |
| 68 |
|
| 69 |
return self::$instance; |
| 70 |
} |
| 71 |
|
| 72 |
/** |
| 73 |
* One padded `label: value` line. |
| 74 |
* |
| 75 |
* @since 1.8.13 |
| 76 |
* |
| 77 |
* @param string $label The label, without its colon. |
| 78 |
* @param string $value The value. |
| 79 |
* @return string |
| 80 |
*/ |
| 81 |
private function line( $label, $value ) { |
| 82 |
return str_pad( $label . ':', self::LABEL_WIDTH ) . $value . "\n"; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Make sure is_plugin_active() exists before the detection helpers run. |
| 87 |
* |
| 88 |
* Without this the two plugin-detection lines report a FALSE CLEAN — |
| 89 |
* "None detected" — rather than failing loudly, whenever this section is |
| 90 |
* generated outside wp-admin, where `wp-admin/includes/plugin.php` is not |
| 91 |
* loaded. A section that says no MCP client is installed on a site where |
| 92 |
* WPVibe is active sends support down exactly the wrong path, and it is |
| 93 |
* the one failure mode a `function_exists()` guard alone produces |
| 94 |
* silently. |
| 95 |
* |
| 96 |
* @since 1.8.13 |
| 97 |
* |
| 98 |
* @return void |
| 99 |
*/ |
| 100 |
private static function load_plugin_api() { |
| 101 |
if ( ! function_exists( 'is_plugin_active' ) && is_readable( ABSPATH . 'wp-admin/includes/plugin.php' ) ) { |
| 102 |
include_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 103 |
} |
| 104 |
} |
| 105 |
|
| 106 |
/** |
| 107 |
* Append the Abilities API section to Tools > System Info. |
| 108 |
* |
| 109 |
* The filter is a STRING filter: each contributor appends |
| 110 |
* "-- Section Title\n\n" followed by "key: value" lines, matching the |
| 111 |
* built-in sections. |
| 112 |
* |
| 113 |
* @since 1.8.13 |
| 114 |
* |
| 115 |
* @param string $sections Existing add-on sections. |
| 116 |
* @return string |
| 117 |
*/ |
| 118 |
public function render( $sections ) { |
| 119 |
$available = function_exists( 'wp_register_ability' ); |
| 120 |
$count = 0; |
| 121 |
$names = array(); |
| 122 |
|
| 123 |
if ( $available ) { |
| 124 |
foreach ( wp_get_abilities() as $ability ) { |
| 125 |
if ( 0 === strpos( $ability->get_name(), 'charitable/' ) ) { |
| 126 |
++$count; |
| 127 |
$names[] = str_replace( 'charitable/', '', $ability->get_name() ); |
| 128 |
} |
| 129 |
} |
| 130 |
} |
| 131 |
|
| 132 |
/* |
| 133 |
* The LEADING newline is the section separator, and it is not |
| 134 |
* optional. Every built-in section is emitted as |
| 135 |
* "\n" . '-- Title' . "\n\n" (see Charitable_Tools_System_Info), |
| 136 |
* so the blank line above a heading belongs to the heading rather |
| 137 |
* than to the section before it. Without it this heading renders |
| 138 |
* flush against the previous section's last value - which is what |
| 139 |
* it did until 1.8.18 - and there is correspondingly NO trailing |
| 140 |
* blank line below, because the next contributor supplies its own. |
| 141 |
*/ |
| 142 |
$section = "\n-- Charitable Abilities API\n\n"; |
| 143 |
|
| 144 |
/* |
| 145 |
* "requires WordPress 6.9" was the whole of this message and it is |
| 146 |
* not the whole truth: the standalone Abilities API feature plugin |
| 147 |
* provides the same functions on an older core, so a site running |
| 148 |
* 6.8 plus that plugin is fully supported. Telling its owner to |
| 149 |
* update WordPress would be wrong advice in the one output support |
| 150 |
* reads first. |
| 151 |
*/ |
| 152 |
$section .= $this->line( |
| 153 |
'API available', |
| 154 |
$available |
| 155 |
? 'Yes' |
| 156 |
: 'No (needs the Abilities API - WordPress 6.9 or later, or the standalone feature plugin; this site runs ' . get_bloginfo( 'version' ) . ')' |
| 157 |
); |
| 158 |
|
| 159 |
/* |
| 160 |
* WHERE the API comes from, which the line above cannot say. Core and |
| 161 |
* the feature plugin are not interchangeable in support terms: the |
| 162 |
* plugin version moves independently of WordPress, so "abilities |
| 163 |
* behave differently on two sites running the same WordPress" is only |
| 164 |
* explicable with this line present. |
| 165 |
*/ |
| 166 |
$section .= $this->line( 'Abilities API source', $this->api_source() ); |
| 167 |
|
| 168 |
$section .= $this->line( |
| 169 |
'Category registered', |
| 170 |
$available && function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( 'charitable' ) ? 'Yes' : 'No' |
| 171 |
); |
| 172 |
$section .= $this->line( 'Charitable abilities', (string) $count ); |
| 173 |
|
| 174 |
/* |
| 175 |
* The AI write gate. First thing to check on "the assistant says it |
| 176 |
* cannot change anything", and it is off on a site that has never set |
| 177 |
* it, so the common case is an owner who does not know it exists. |
| 178 |
* |
| 179 |
* TWO lines rather than one, because the stored setting and what the |
| 180 |
* gate actually permits can legitimately disagree: a snippet plugin |
| 181 |
* filtering `charitable_abilities_allow_write` shows up as a mismatch |
| 182 |
* between them rather than being invisible. |
| 183 |
* |
| 184 |
* 1.8.13 briefly reported this per scope, when write access was split |
| 185 |
* three ways. One switch, one stored value - so the per-scope block |
| 186 |
* and the AND-semantics caveat about the legacy flag are both gone. |
| 187 |
*/ |
| 188 |
if ( $available ) { |
| 189 |
$stored = ! empty( charitable_get_option( 'ai_mcp_write_enabled', '' ) ); |
| 190 |
|
| 191 |
$section .= $this->line( |
| 192 |
'AI write access', |
| 193 |
$stored ? 'On' : 'Off (reads work; changes are refused)' |
| 194 |
); |
| 195 |
|
| 196 |
$section .= $this->line( |
| 197 |
'Writes allowed now', |
| 198 |
( Charitable_Abilities_Registrar::write_enabled() ? 'Yes' : 'No' ) |
| 199 |
. ' (resolved through charitable_abilities_allow_write; a mismatch with the line above means a filter is overriding the stored setting)' |
| 200 |
); |
| 201 |
|
| 202 |
$section .= $this->line( 'Writable abilities', $this->count_write_abilities() . ' of ' . $count ); |
| 203 |
} |
| 204 |
|
| 205 |
$section .= $this->line( 'REST route', rest_url( 'wp-abilities/v1/abilities' ) ); |
| 206 |
$section .= $this->line( |
| 207 |
'Pretty permalinks', |
| 208 |
'' !== (string) get_option( 'permalink_structure' ) |
| 209 |
? 'Yes' |
| 210 |
: 'No (plain permalinks; /wp-json/ will not resolve and clients must use ?rest_route=)' |
| 211 |
); |
| 212 |
$section .= $this->line( 'MCP clients active', implode( ', ', $this->mcp_client_labels() ) ); |
| 213 |
|
| 214 |
/* |
| 215 |
* Known REST and application-password blockers. Detected by plugin |
| 216 |
* presence only, and worded as such: these plugins are all legitimate |
| 217 |
* and most installs of them do not block anything. But when a client |
| 218 |
* cannot authenticate and every Charitable-side check passes, this is |
| 219 |
* the next place to look, and support cannot ask a customer to list |
| 220 |
* their security plugins faster than reading it here. |
| 221 |
*/ |
| 222 |
$section .= $this->line( 'REST access blockers', implode( ', ', $this->get_rest_blockers() ) ); |
| 223 |
|
| 224 |
/* |
| 225 |
* REST-visible count, next to the registered count above. |
| 226 |
* |
| 227 |
* These two diverging IS open item 51: a permission_callback that is |
| 228 |
* not callable makes WordPress drop the ability from the REST list |
| 229 |
* while leaving it registered, and nothing anywhere reported it. A |
| 230 |
* partial deploy produced exactly that — 29 registered, 20 visible — |
| 231 |
* and the only symptom was an assistant quietly not seeing half the |
| 232 |
* plugin. Reported unconditionally so the comparison is always in |
| 233 |
* front of whoever pastes System Info into a ticket. |
| 234 |
*/ |
| 235 |
$section .= $this->line( 'REST-visible abilities', $this->count_rest_visible() ); |
| 236 |
|
| 237 |
/* |
| 238 |
* Every ability on the site, not just ours. Two reasons support needs |
| 239 |
* it: it distinguishes "Charitable registered nothing" from "the |
| 240 |
* Abilities API itself registered nothing", and it shows how much |
| 241 |
* else is competing for the client's attention — an assistant working |
| 242 |
* against a very large tool surface behaves differently from one with |
| 243 |
* only Charitable loaded. |
| 244 |
*/ |
| 245 |
$section .= $this->line( 'Abilities (all plugins)', $this->count_all_abilities() ); |
| 246 |
|
| 247 |
/* |
| 248 |
* Application passwords. WPVibe and every other remote MCP client |
| 249 |
* authenticates with one, and availability is not a given: the |
| 250 |
* feature is filterable and is unavailable over plain HTTP. When it |
| 251 |
* is off, connecting fails with nothing else to see. |
| 252 |
*/ |
| 253 |
$section .= $this->line( 'Application passwords', $this->application_passwords_status() ); |
| 254 |
|
| 255 |
/* |
| 256 |
* Whether the log is even on, which every log-derived line below |
| 257 |
* depends on and none of them could previously state. `Ability log |
| 258 |
* entries (7d): 0` was ambiguous between three completely different |
| 259 |
* situations - never connected, connected and only reading, and |
| 260 |
* logging switched off - and only one of them is a problem. |
| 261 |
*/ |
| 262 |
$section .= $this->line( 'Logging', $this->logging_status() ); |
| 263 |
$section .= $this->line( 'Ability log entries (7d)', $this->count_recent_log_entries() ); |
| 264 |
|
| 265 |
/* |
| 266 |
* Last invocation, because a count of 0 cannot distinguish "never |
| 267 |
* connected" from "connected, and only reading" — reads are not |
| 268 |
* logged by default (see below), so a working read-only integration |
| 269 |
* looks identical to a broken one. |
| 270 |
*/ |
| 271 |
$section .= $this->line( 'Last ability invoked', $this->last_log_entry() ); |
| 272 |
|
| 273 |
/* |
| 274 |
* The last refusal, separately, because it names the ability. The |
| 275 |
* counter below says how many calls were denied; only this says which |
| 276 |
* one, and "which one" is what turns a denial count into a fix. |
| 277 |
*/ |
| 278 |
$section .= $this->line( 'Last denial or refusal', $this->last_denial() ); |
| 279 |
|
| 280 |
/* |
| 281 |
* Whether read logging is on. Without this line an empty log reads as |
| 282 |
* a broken integration when it is simply the default: writes and |
| 283 |
* denials always log, reads are opt-in, and a WPVibe session is |
| 284 |
* mostly reads. |
| 285 |
*/ |
| 286 |
$section .= $this->line( |
| 287 |
'Read logging', |
| 288 |
apply_filters( 'charitable_abilities_log_reads', false, '' ) |
| 289 |
? 'On' |
| 290 |
: 'Off (default; writes and denials still log. Filter: charitable_abilities_log_reads)' |
| 291 |
); |
| 292 |
|
| 293 |
/* |
| 294 |
* The counter, which is the only line here that sees READS - and so |
| 295 |
* the only one that can answer "has this ever been used". Independent |
| 296 |
* of the log entirely: unaffected by logging being switched off, by |
| 297 |
* retention pruning, and by reads being opt-in. See |
| 298 |
* Charitable_Abilities_Usage. |
| 299 |
*/ |
| 300 |
$section .= $this->line( 'Ability calls (all time)', $this->usage_summary() ); |
| 301 |
$section .= $this->line( 'Busiest abilities', $this->busiest_abilities() ); |
| 302 |
|
| 303 |
if ( $count > 0 ) { |
| 304 |
$section .= $this->line( 'Registered', implode( ', ', $names ) ); |
| 305 |
} |
| 306 |
|
| 307 |
/* |
| 308 |
* A trailing blank line, added 2026-09-16 because the assumption in |
| 309 |
* the comment above - that the next contributor supplies its own |
| 310 |
* leading newline - is not true in practice. charitable-rewards |
| 311 |
* builds `'-- Charitable Rewards' . "\n\n"` with NO leading newline |
| 312 |
* (its own docblock says it matches the built-in format, so the two |
| 313 |
* plugins simply read the convention differently), and its heading |
| 314 |
* therefore rendered flush against this section's last value. |
| 315 |
* |
| 316 |
* Defending this section's bottom edge is the fix that does not |
| 317 |
* require every addon to agree. The trade is one extra blank line |
| 318 |
* before any section that DOES supply its own leading newline, which |
| 319 |
* is cosmetic in a plain-text dump and strictly better than two |
| 320 |
* headings running together. |
| 321 |
*/ |
| 322 |
$section .= "\n"; |
| 323 |
|
| 324 |
return $sections . $section; |
| 325 |
} |
| 326 |
|
| 327 |
/** |
| 328 |
* Count the Charitable abilities that can change something. |
| 329 |
* |
| 330 |
* Derived from the `readonly` annotation, the same source the gate itself |
| 331 |
* uses, so this number cannot drift from what is actually gated. |
| 332 |
* |
| 333 |
* @since 1.8.13 |
| 334 |
* |
| 335 |
* @return int |
| 336 |
*/ |
| 337 |
private function count_write_abilities() { |
| 338 |
|
| 339 |
if ( ! function_exists( 'wp_get_abilities' ) ) { |
| 340 |
return 0; |
| 341 |
} |
| 342 |
|
| 343 |
$writes = 0; |
| 344 |
|
| 345 |
foreach ( wp_get_abilities() as $ability ) { |
| 346 |
if ( ! is_object( $ability ) || ! method_exists( $ability, 'get_name' ) ) { |
| 347 |
continue; |
| 348 |
} |
| 349 |
|
| 350 |
if ( 0 !== strpos( $ability->get_name(), 'charitable/' ) ) { |
| 351 |
continue; |
| 352 |
} |
| 353 |
|
| 354 |
$meta = method_exists( $ability, 'get_meta' ) ? (array) $ability->get_meta() : array(); |
| 355 |
$annotations = isset( $meta['annotations'] ) ? (array) $meta['annotations'] : array(); |
| 356 |
|
| 357 |
if ( empty( $annotations['readonly'] ) ) { |
| 358 |
++$writes; |
| 359 |
} |
| 360 |
} |
| 361 |
|
| 362 |
return $writes; |
| 363 |
} |
| 364 |
|
| 365 |
/** |
| 366 |
* Whether the Abilities API comes from core or from the feature plugin. |
| 367 |
* |
| 368 |
* Resolved by reflecting on where wp_register_ability() is actually |
| 369 |
* defined, rather than by comparing version numbers. A version check |
| 370 |
* would get this wrong in both directions: a 6.8 site with the feature |
| 371 |
* plugin reads as unsupported, and a 6.9 site whose plugin copy loads |
| 372 |
* first reads as core. |
| 373 |
* |
| 374 |
* @since 1.8.13 |
| 375 |
* |
| 376 |
* @return string |
| 377 |
*/ |
| 378 |
private function api_source() { |
| 379 |
if ( ! function_exists( 'wp_register_ability' ) ) { |
| 380 |
return 'not available'; |
| 381 |
} |
| 382 |
|
| 383 |
try { |
| 384 |
$file = ( new ReflectionFunction( 'wp_register_ability' ) )->getFileName(); |
| 385 |
} catch ( ReflectionException $e ) { |
| 386 |
return 'unknown (could not be resolved)'; |
| 387 |
} |
| 388 |
|
| 389 |
if ( ! is_string( $file ) || '' === $file ) { |
| 390 |
return 'unknown (could not be resolved)'; |
| 391 |
} |
| 392 |
|
| 393 |
$file = wp_normalize_path( $file ); |
| 394 |
|
| 395 |
/* |
| 396 |
* realpath() both sides before comparing, not just wp_normalize_path(). |
| 397 |
* On a system where ABSPATH sits behind a symlink - e.g. macOS's |
| 398 |
* /tmp -> /private/tmp, which is exactly where the WP core test |
| 399 |
* install lives - ReflectionFunction::getFileName() returns the |
| 400 |
* OS-resolved real path while ABSPATH is whatever unresolved string |
| 401 |
* WordPress booted with. Comparing the two directly makes a file |
| 402 |
* that IS inside wp-includes fail the prefix check purely because of |
| 403 |
* the symlink, misreporting core as "a plugin, not core |
| 404 |
* (wp-includes)" - confirmed: the mismatch disappears once both |
| 405 |
* sides are resolved the same way. Falling back to the |
| 406 |
* unresolved/normalized path when realpath() returns false (a |
| 407 |
* stream-wrapped or otherwise unresolvable path) keeps this the |
| 408 |
* exact same comparison the method always made in that case. |
| 409 |
* |
| 410 |
* @since 1.8.13 |
| 411 |
*/ |
| 412 |
$includes_dir = realpath( ABSPATH . WPINC ); |
| 413 |
$includes_dir = wp_normalize_path( false !== $includes_dir ? $includes_dir : ( ABSPATH . WPINC ) ); |
| 414 |
|
| 415 |
$real_file = realpath( $file ); |
| 416 |
$real_file = wp_normalize_path( false !== $real_file ? $real_file : $file ); |
| 417 |
|
| 418 |
if ( 0 === strpos( $real_file, $includes_dir ) ) { |
| 419 |
return 'WordPress core ' . get_bloginfo( 'version' ); |
| 420 |
} |
| 421 |
|
| 422 |
return sprintf( |
| 423 |
'a plugin, not core (%s) - the standalone Abilities API, which versions independently of WordPress', |
| 424 |
basename( dirname( $file ) ) |
| 425 |
); |
| 426 |
} |
| 427 |
|
| 428 |
/** |
| 429 |
* Detect installed MCP clients that consume the Abilities API. |
| 430 |
* |
| 431 |
* Reported so a support reply can distinguish "Charitable registered |
| 432 |
* nothing" from "nothing is asking". Detection is by plugin presence, |
| 433 |
* not by capability: any MCP client reads whatever is registered, so the |
| 434 |
* list is informational. |
| 435 |
* |
| 436 |
* @since 1.8.13 |
| 437 |
* |
| 438 |
* @return array |
| 439 |
*/ |
| 440 |
private function mcp_client_labels() { |
| 441 |
/* |
| 442 |
* The basename map lives on Charitable_Abilities, not here, because |
| 443 |
* the usage check-in needs the same detection and wants the slug |
| 444 |
* half of it. Two copies is how System Info comes to report |
| 445 |
* "WPVibe" on a site whose telemetry row says no client at all. |
| 446 |
*/ |
| 447 |
$active = array(); |
| 448 |
|
| 449 |
foreach ( Charitable_Abilities::get_active_mcp_clients() as $entry ) { |
| 450 |
$active[] = $entry['label']; |
| 451 |
} |
| 452 |
|
| 453 |
/** |
| 454 |
* Filter the MCP clients reported in System Info. |
| 455 |
* |
| 456 |
* @since 1.8.13 |
| 457 |
* |
| 458 |
* @param array $active Labels of detected MCP clients. |
| 459 |
*/ |
| 460 |
$active = (array) apply_filters( 'charitable_abilities_mcp_clients', $active ); |
| 461 |
|
| 462 |
return ! empty( $active ) ? $active : array( 'None detected' ); |
| 463 |
} |
| 464 |
|
| 465 |
/** |
| 466 |
* Detect plugins known to be capable of blocking REST or app passwords. |
| 467 |
* |
| 468 |
* Presence is NOT proof of blocking, and the wording says so. These are |
| 469 |
* all legitimate security plugins and most installs block nothing; but |
| 470 |
* filtering `rest_authentication_errors` or switching off application |
| 471 |
* passwords is a documented option in every one of them, and it produces |
| 472 |
* a failure where every Charitable-side check passes and the client |
| 473 |
* simply cannot sign in. |
| 474 |
* |
| 475 |
* Deliberately NOT done by introspecting $wp_filter: core registers its |
| 476 |
* own callbacks on those hooks, so a count cannot separate ours from |
| 477 |
* theirs without hardcoding core's set, which then rots each release. |
| 478 |
* |
| 479 |
* @since 1.8.13 |
| 480 |
* |
| 481 |
* @return array |
| 482 |
*/ |
| 483 |
private function get_rest_blockers() { |
| 484 |
self::load_plugin_api(); |
| 485 |
|
| 486 |
$known = array( |
| 487 |
'wordfence/wordfence.php' => 'Wordfence', |
| 488 |
'better-wp-security/better-wp-security.php' => 'Solid Security', |
| 489 |
'sucuri-scanner/sucuri.php' => 'Sucuri Security', |
| 490 |
'wp-cerber/wp-cerber.php' => 'WP Cerber', |
| 491 |
'all-in-one-wp-security-and-firewall/wp-security.php' => 'All In One WP Security', |
| 492 |
'disable-json-api/disable-json-api.php' => 'Disable REST API', |
| 493 |
'disable-wp-rest-api/disable-wp-rest-api.php' => 'Disable WP REST API', |
| 494 |
); |
| 495 |
|
| 496 |
$found = array(); |
| 497 |
|
| 498 |
foreach ( $known as $plugin => $label ) { |
| 499 |
if ( function_exists( 'is_plugin_active' ) && is_plugin_active( $plugin ) ) { |
| 500 |
$found[] = $label; |
| 501 |
} |
| 502 |
} |
| 503 |
|
| 504 |
/** |
| 505 |
* Filter the REST-blocking plugins reported in System Info. |
| 506 |
* |
| 507 |
* @since 1.8.13 |
| 508 |
* |
| 509 |
* @param array $found Labels of detected plugins. |
| 510 |
*/ |
| 511 |
$found = (array) apply_filters( 'charitable_abilities_rest_blockers', $found ); |
| 512 |
|
| 513 |
if ( empty( $found ) ) { |
| 514 |
return array( 'None detected' ); |
| 515 |
} |
| 516 |
|
| 517 |
return array( implode( ', ', $found ) . ' - can each restrict REST or application passwords; none does by default, so check their settings before suspecting Charitable' ); |
| 518 |
} |
| 519 |
|
| 520 |
/** |
| 521 |
* How many Charitable abilities the REST list actually exposes. |
| 522 |
* |
| 523 |
* Delegates to the diagnostic registrar rather than repeating the |
| 524 |
* rest_do_request() walk. That method is the one get-site-environment |
| 525 |
* reports from, so the two surfaces cannot disagree — and disagreeing |
| 526 |
* counts are precisely the failure this line exists to reveal. |
| 527 |
* |
| 528 |
* @since 1.8.13 |
| 529 |
* |
| 530 |
* @return string |
| 531 |
*/ |
| 532 |
private function count_rest_visible() { |
| 533 |
if ( ! class_exists( 'Charitable_Diagnostic_Abilities' ) || ! function_exists( 'rest_do_request' ) ) { |
| 534 |
return 'unknown (diagnostic registrar not loaded)'; |
| 535 |
} |
| 536 |
|
| 537 |
/* |
| 538 |
* Refuse to measure from the command line rather than report a |
| 539 |
* false mismatch. |
| 540 |
* |
| 541 |
* rest_do_request() under WP-CLI runs with no authenticated user, so |
| 542 |
* the abilities list comes back empty and this reported |
| 543 |
* "all registered but only 0 visible" on a completely healthy site |
| 544 |
* (observed as "29 registered, 0 visible" while the registry still |
| 545 |
* held 29; Lite ships 24 today, and the shape of the false alarm is |
| 546 |
* the point rather than the number). |
| 547 |
* That is a false alarm in the one output people paste into support |
| 548 |
* tickets, pointing at open item 51 when nothing is wrong — worse |
| 549 |
* than reporting nothing. Tools > System Info runs in a real admin |
| 550 |
* request, where the measurement is valid: it reports the full |
| 551 |
* registered count there, matching the "Charitable abilities" line. |
| 552 |
*/ |
| 553 |
if ( defined( 'WP_CLI' ) && WP_CLI ) { |
| 554 |
return 'not measurable from WP-CLI (no authenticated request); open Tools > System Info in the admin instead'; |
| 555 |
} |
| 556 |
|
| 557 |
$visible = (int) Charitable_Diagnostic_Abilities::count_rest_visible_charitable_abilities(); |
| 558 |
$registered = 0; |
| 559 |
|
| 560 |
if ( function_exists( 'wp_get_abilities' ) ) { |
| 561 |
foreach ( wp_get_abilities() as $ability ) { |
| 562 |
if ( 0 === strpos( $ability->get_name(), 'charitable/' ) ) { |
| 563 |
++$registered; |
| 564 |
} |
| 565 |
} |
| 566 |
} |
| 567 |
|
| 568 |
if ( $visible === $registered ) { |
| 569 |
return (string) $visible; |
| 570 |
} |
| 571 |
|
| 572 |
/* translators: 1: REST-visible count, 2: registered count. */ |
| 573 |
return sprintf( |
| 574 |
'%1$d — MISMATCH: %2$d registered but only %1$d visible over REST. An ability with a non-callable permission_callback is dropped from the list silently; check for a partial deploy.', |
| 575 |
$visible, |
| 576 |
$registered |
| 577 |
); |
| 578 |
} |
| 579 |
|
| 580 |
/** |
| 581 |
* Every registered ability on the site, and how many are Charitable's. |
| 582 |
* |
| 583 |
* @since 1.8.13 |
| 584 |
* |
| 585 |
* @return string |
| 586 |
*/ |
| 587 |
private function count_all_abilities() { |
| 588 |
if ( ! function_exists( 'wp_get_abilities' ) ) { |
| 589 |
return 'unknown (the Abilities API is not available)'; |
| 590 |
} |
| 591 |
|
| 592 |
$all = 0; |
| 593 |
$ours = 0; |
| 594 |
|
| 595 |
foreach ( wp_get_abilities() as $ability ) { |
| 596 |
++$all; |
| 597 |
|
| 598 |
if ( is_object( $ability ) && method_exists( $ability, 'get_name' ) && 0 === strpos( $ability->get_name(), 'charitable/' ) ) { |
| 599 |
++$ours; |
| 600 |
} |
| 601 |
} |
| 602 |
|
| 603 |
return sprintf( '%d registered on this site, %d of them Charitable', $all, $ours ); |
| 604 |
} |
| 605 |
|
| 606 |
/** |
| 607 |
* Whether a remote client could authenticate with an application password, |
| 608 |
* and whether any actually exists. |
| 609 |
* |
| 610 |
* Guarded against WP-CLI for the same reason as count_rest_visible(): |
| 611 |
* wp_is_application_passwords_available() consults is_ssl(), which is |
| 612 |
* false with no HTTP request, so this reported "NOT available" on a site |
| 613 |
* where WPVibe was authenticating perfectly over HTTPS. |
| 614 |
* |
| 615 |
* @since 1.8.13 |
| 616 |
* |
| 617 |
* @return string |
| 618 |
*/ |
| 619 |
private function application_passwords_status() { |
| 620 |
if ( ! function_exists( 'wp_is_application_passwords_available' ) ) { |
| 621 |
return 'unknown (this WordPress has no application passwords API)'; |
| 622 |
} |
| 623 |
|
| 624 |
if ( defined( 'WP_CLI' ) && WP_CLI ) { |
| 625 |
return 'not measurable from WP-CLI (is_ssl() is false with no request); open Tools > System Info in the admin instead'; |
| 626 |
} |
| 627 |
|
| 628 |
if ( ! wp_is_application_passwords_available() ) { |
| 629 |
return 'NOT available — a remote client such as WPVibe cannot authenticate. Requires HTTPS, and can be switched off by the wp_is_application_passwords_available filter.'; |
| 630 |
} |
| 631 |
|
| 632 |
/* |
| 633 |
* Available is only half the answer. A site that has never created |
| 634 |
* one has a client that cannot connect, and "Available" alone reads |
| 635 |
* as though authentication is fine. The inventory below is counts |
| 636 |
* and a date only - never the password NAMES, which are |
| 637 |
* user-authored strings and belong to the same class of content as |
| 638 |
* log messages. |
| 639 |
*/ |
| 640 |
return 'Available. ' . $this->application_password_inventory(); |
| 641 |
} |
| 642 |
|
| 643 |
/** |
| 644 |
* How many application passwords exist, and when the newest was made. |
| 645 |
* |
| 646 |
* One indexed usermeta query rather than a walk over every user: a site |
| 647 |
* with 40,000 subscribers is exactly the kind that also has a support |
| 648 |
* ticket open, and get_users() there would make System Info time out. |
| 649 |
* |
| 650 |
* @since 1.8.13 |
| 651 |
* |
| 652 |
* @global WPDB $wpdb |
| 653 |
* @return string |
| 654 |
*/ |
| 655 |
private function application_password_inventory() { |
| 656 |
global $wpdb; |
| 657 |
|
| 658 |
if ( ! class_exists( 'WP_Application_Passwords' ) ) { |
| 659 |
return ''; |
| 660 |
} |
| 661 |
|
| 662 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 663 |
$rows = $wpdb->get_col( |
| 664 |
$wpdb->prepare( |
| 665 |
"SELECT meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 666 |
WP_Application_Passwords::USERMETA_KEY_APPLICATION_PASSWORDS |
| 667 |
) |
| 668 |
); |
| 669 |
|
| 670 |
$passwords = 0; |
| 671 |
$users = 0; |
| 672 |
$newest = 0; |
| 673 |
|
| 674 |
foreach ( (array) $rows as $row ) { |
| 675 |
$entries = maybe_unserialize( $row ); |
| 676 |
|
| 677 |
if ( ! is_array( $entries ) || empty( $entries ) ) { |
| 678 |
continue; |
| 679 |
} |
| 680 |
|
| 681 |
++$users; |
| 682 |
$passwords += count( $entries ); |
| 683 |
|
| 684 |
foreach ( $entries as $entry ) { |
| 685 |
if ( isset( $entry['created'] ) && (int) $entry['created'] > $newest ) { |
| 686 |
$newest = (int) $entry['created']; |
| 687 |
} |
| 688 |
} |
| 689 |
} |
| 690 |
|
| 691 |
if ( 0 === $passwords ) { |
| 692 |
return 'No application password has ever been created on this site, so no remote client can sign in yet.'; |
| 693 |
} |
| 694 |
|
| 695 |
/* |
| 696 |
* An entry with no `created` key would otherwise render as |
| 697 |
* "newest created 1970-01-01", which reads as corrupt data rather |
| 698 |
* than as a missing field. |
| 699 |
*/ |
| 700 |
$newest_text = $newest > 0 ? 'newest created ' . gmdate( 'Y-m-d', $newest ) : 'creation date unknown'; |
| 701 |
|
| 702 |
return sprintf( |
| 703 |
'%d password(s) across %d user(s)%s; %s.', |
| 704 |
$passwords, |
| 705 |
$users, |
| 706 |
is_multisite() ? ' network-wide, as usermeta is shared across a network' : '', |
| 707 |
$newest_text |
| 708 |
); |
| 709 |
} |
| 710 |
|
| 711 |
/** |
| 712 |
* Whether logging is on, and how long rows survive. |
| 713 |
* |
| 714 |
* Every log-derived line in this section is meaningless without it. `0` |
| 715 |
* entries was ambiguous between three unrelated states and only one of |
| 716 |
* them is a fault. |
| 717 |
* |
| 718 |
* @since 1.8.13 |
| 719 |
* |
| 720 |
* @return string |
| 721 |
*/ |
| 722 |
private function logging_status() { |
| 723 |
if ( ! class_exists( 'Charitable_Log' ) ) { |
| 724 |
return 'unknown (the logger is not loaded)'; |
| 725 |
} |
| 726 |
|
| 727 |
if ( ! Charitable_Log::is_enabled() ) { |
| 728 |
return 'DISABLED under Tools > Logs - so every log-derived count below reads 0 no matter what an assistant is doing. The ability call counter further down is unaffected.'; |
| 729 |
} |
| 730 |
|
| 731 |
return sprintf( 'Enabled, %d-day retention', (int) Charitable_Log::get_retention_days() ); |
| 732 |
} |
| 733 |
|
| 734 |
/** |
| 735 |
* When an ability was last invoked, or a plain statement that none has been. |
| 736 |
* |
| 737 |
* @since 1.8.13 |
| 738 |
* |
| 739 |
* @global WPDB $wpdb |
| 740 |
* @return string |
| 741 |
*/ |
| 742 |
private function last_log_entry() { |
| 743 |
global $wpdb; |
| 744 |
|
| 745 |
$table = $wpdb->prefix . 'charitable_logs'; |
| 746 |
|
| 747 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 748 |
if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) { |
| 749 |
return 'never (no log table)'; |
| 750 |
} |
| 751 |
|
| 752 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter |
| 753 |
$row = $wpdb->get_row( |
| 754 |
$wpdb->prepare( |
| 755 |
// $table is $wpdb->prefix . 'charitable_logs' and carries no user input; MySQL cannot bind a table identifier as a prepared parameter, so interpolation is the only option. |
| 756 |
"SELECT title, level, create_at FROM {$table} WHERE types LIKE %s ORDER BY id DESC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 757 |
'%abilities%' |
| 758 |
) |
| 759 |
); |
| 760 |
|
| 761 |
if ( empty( $row ) ) { |
| 762 |
return 'never'; |
| 763 |
} |
| 764 |
|
| 765 |
return sprintf( '%s at %s [%s]', (string) $row->title, (string) $row->create_at, (string) $row->level ); |
| 766 |
} |
| 767 |
|
| 768 |
/** |
| 769 |
* The most recent denial or write-gate refusal, which names the ability. |
| 770 |
* |
| 771 |
* Both are logged at `warning` by log_denial() and log_write_refused(), |
| 772 |
* and both are the cases where knowing WHICH ability was refused is the |
| 773 |
* difference between a diagnosis and a number. |
| 774 |
* |
| 775 |
* @since 1.8.13 |
| 776 |
* |
| 777 |
* @global WPDB $wpdb |
| 778 |
* @return string |
| 779 |
*/ |
| 780 |
private function last_denial() { |
| 781 |
global $wpdb; |
| 782 |
|
| 783 |
$table = $wpdb->prefix . 'charitable_logs'; |
| 784 |
|
| 785 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 786 |
if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) { |
| 787 |
return 'never (no log table)'; |
| 788 |
} |
| 789 |
|
| 790 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter |
| 791 |
$row = $wpdb->get_row( |
| 792 |
$wpdb->prepare( |
| 793 |
// $table is $wpdb->prefix . 'charitable_logs' and carries no user input; MySQL cannot bind a table identifier as a prepared parameter, so interpolation is the only option. |
| 794 |
"SELECT title, message, create_at FROM {$table} WHERE types LIKE %s AND level = %s ORDER BY id DESC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 795 |
'%abilities%', |
| 796 |
'warning' |
| 797 |
) |
| 798 |
); |
| 799 |
|
| 800 |
if ( empty( $row ) ) { |
| 801 |
return 'none recorded'; |
| 802 |
} |
| 803 |
|
| 804 |
return sprintf( '%s at %s - %s', (string) $row->title, (string) $row->create_at, (string) $row->message ); |
| 805 |
} |
| 806 |
|
| 807 |
/** |
| 808 |
* How many ability log entries were written in the last seven days. |
| 809 |
* |
| 810 |
* Reads the `types` and `create_at` columns. Those names matter: the |
| 811 |
* table has no `log_type` or `log_date`, and querying the names you would |
| 812 |
* expect returns 0 rather than an error, which reads as "the integration |
| 813 |
* has never logged anything" on a site with 36 entries. |
| 814 |
* |
| 815 |
* @since 1.8.13 |
| 816 |
* |
| 817 |
* @global WPDB $wpdb |
| 818 |
* @return string |
| 819 |
*/ |
| 820 |
private function count_recent_log_entries() { |
| 821 |
global $wpdb; |
| 822 |
|
| 823 |
$table = $wpdb->prefix . 'charitable_logs'; |
| 824 |
|
| 825 |
/* |
| 826 |
* The table is created lazily, on the first log write. Querying a |
| 827 |
* table that does not exist yet would emit a database error into the |
| 828 |
* System Info output — the one place a support diagnosis must not |
| 829 |
* contain noise. |
| 830 |
*/ |
| 831 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 832 |
if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) { |
| 833 |
return '0 (no log entries yet)'; |
| 834 |
} |
| 835 |
|
| 836 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter |
| 837 |
$count = $wpdb->get_var( |
| 838 |
$wpdb->prepare( |
| 839 |
// $table is $wpdb->prefix . 'charitable_logs' and carries no user input; MySQL cannot bind a table identifier as a prepared parameter, so interpolation is the only option. |
| 840 |
"SELECT COUNT(*) FROM {$table} WHERE types LIKE %s AND create_at >= %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 841 |
'%abilities%', |
| 842 |
gmdate( 'Y-m-d H:i:s', time() - ( 7 * DAY_IN_SECONDS ) ) |
| 843 |
) |
| 844 |
); |
| 845 |
|
| 846 |
return (string) (int) $count; |
| 847 |
} |
| 848 |
|
| 849 |
/** |
| 850 |
* The usage counter, rendered. |
| 851 |
* |
| 852 |
* The one line here that counts READS, and therefore the only one that |
| 853 |
* can distinguish a working read-only integration from one that has |
| 854 |
* never run. See Charitable_Abilities_Usage for why this cannot come |
| 855 |
* from the log. |
| 856 |
* |
| 857 |
* @since 1.8.13 |
| 858 |
* |
| 859 |
* @return string |
| 860 |
*/ |
| 861 |
private function usage_summary() { |
| 862 |
if ( ! class_exists( 'Charitable_Abilities_Usage' ) ) { |
| 863 |
return 'unknown (the usage counter is not loaded)'; |
| 864 |
} |
| 865 |
|
| 866 |
$usage = Charitable_Abilities_Usage::get(); |
| 867 |
|
| 868 |
if ( 0 === $usage['total'] ) { |
| 869 |
return 'none - no Charitable ability has ever been invoked on this site'; |
| 870 |
} |
| 871 |
|
| 872 |
return sprintf( |
| 873 |
'%d (%d ok, %d denied, %d refused by the write gate, %d failed), first %s, last %s', |
| 874 |
$usage['total'], |
| 875 |
$usage['ok'], |
| 876 |
$usage['denied'], |
| 877 |
$usage['refused'], |
| 878 |
$usage['failed'], |
| 879 |
'' !== $usage['first_at'] ? $usage['first_at'] : 'unknown', |
| 880 |
'' !== $usage['last_at'] ? $usage['last_at'] : 'unknown' |
| 881 |
); |
| 882 |
} |
| 883 |
|
| 884 |
/** |
| 885 |
* The five most-called abilities. |
| 886 |
* |
| 887 |
* Answers "what is the assistant actually doing", which the totals |
| 888 |
* cannot. On a site reporting a problem with one area, this says |
| 889 |
* immediately whether that area is being reached at all. |
| 890 |
* |
| 891 |
* @since 1.8.13 |
| 892 |
* |
| 893 |
* @return string |
| 894 |
*/ |
| 895 |
private function busiest_abilities() { |
| 896 |
if ( ! class_exists( 'Charitable_Abilities_Usage' ) ) { |
| 897 |
return 'unknown (the usage counter is not loaded)'; |
| 898 |
} |
| 899 |
|
| 900 |
$calls = Charitable_Abilities_Usage::get()['calls']; |
| 901 |
|
| 902 |
if ( empty( $calls ) ) { |
| 903 |
return 'none'; |
| 904 |
} |
| 905 |
|
| 906 |
arsort( $calls ); |
| 907 |
|
| 908 |
$parts = array(); |
| 909 |
|
| 910 |
foreach ( array_slice( $calls, 0, 5, true ) as $slug => $count ) { |
| 911 |
$parts[] = $slug . ' ' . (int) $count; |
| 912 |
} |
| 913 |
|
| 914 |
return implode( ', ', $parts ); |
| 915 |
} |
| 916 |
} |
| 917 |
|
| 918 |
endif; |
| 919 |
|