PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
1.8.13.1 1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 All 212 releases
charitable / includes / abilities / class-charitable-abilities-system-info.php

class-charitable-abilities-system-info.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.13, at includes/abilities/class-charitable-abilities-system-info.php

919 lines 31.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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