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.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 1.7.0.9 All 211 releases
charitable / includes / abilities / class-charitable-abilities.php

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

565 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Bootstraps Charitable abilities for the WordPress Abilities API (WP 6.9+).
4 *
5 * Each domain lives in its own registrar under registrars/. This class only
6 * wires the WordPress hooks, registers the category, and instantiates the
7 * registrars when the API initialises.
8 *
9 * @package Charitable/Classes/Charitable_Abilities
10 * @copyright Copyright (c) 2026, WP Charitable LLC
11 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
12 * @since 1.8.13
13 * @version 1.8.13
14 */
15
16 if ( ! defined( 'ABSPATH' ) ) {
17 exit;
18 }
19
20 if ( ! class_exists( 'Charitable_Abilities' ) ) :
21
22 /**
23 * Charitable_Abilities
24 *
25 * @since 1.8.13
26 */
27 class Charitable_Abilities {
28
29 /**
30 * Single instance.
31 *
32 * @since 1.8.13
33 * @var Charitable_Abilities|null
34 */
35 private static $instance = null;
36
37 /**
38 * MCP client plugins that consume the Abilities API, by plugin basename.
39 *
40 * ONE map with both halves, because two consumers want different halves
41 * of it: System Info renders the label, and the usage check-in sends the
42 * slug. Kept together so they cannot disagree — a second copy is how you
43 * end up with System Info reporting "WPVibe" on a site whose telemetry
44 * row says no client at all, which reads as a telemetry bug and is not
45 * one.
46 *
47 * The slugs are a closed vocabulary the receiver sanitises against, so
48 * changing one is a receiver change too.
49 *
50 * Note `vibe-ai`, not `wpvibe`: WPVibe is published under that slug.
51 *
52 * @since 1.8.13
53 * @var array<string,array<string,string>>
54 */
55 const MCP_CLIENTS = array(
56 'vibe-ai/vibe-ai.php' => array(
57 'slug' => 'wpvibe',
58 'label' => 'WPVibe',
59 ),
60 'wpvibe-ai-mcp/wpvibe-ai-mcp.php' => array(
61 'slug' => 'wpvibe-mcp',
62 'label' => 'WPVibe MCP',
63 ),
64 'mcp-adapter/mcp-adapter.php' => array(
65 'slug' => 'mcp-adapter',
66 'label' => 'WordPress MCP Adapter',
67 ),
68 'wordpress-mcp/wordpress-mcp.php' => array(
69 'slug' => 'wordpress-mcp',
70 'label' => 'WordPress MCP',
71 ),
72 );
73
74 /**
75 * The MCP clients active on this site.
76 *
77 * Loads the plugin API itself rather than guarding on
78 * `function_exists( 'is_plugin_active' )` alone. Both callers can run
79 * outside wp-admin — one in a cron check-in, one wherever the System
80 * Info filter is applied — and a bare guard there reports "no client
81 * installed" on a site running WPVibe. A false clean is the worst of the
82 * three possible answers, because nothing about it looks wrong.
83 *
84 * @since 1.8.13
85 *
86 * @return array<string,array<string,string>> Basename => entry, active only.
87 */
88 public static function get_active_mcp_clients() {
89 if ( ! function_exists( 'is_plugin_active' ) && is_readable( ABSPATH . 'wp-admin/includes/plugin.php' ) ) {
90 include_once ABSPATH . 'wp-admin/includes/plugin.php';
91 }
92
93 if ( ! function_exists( 'is_plugin_active' ) ) {
94 return array();
95 }
96
97 $active = array();
98
99 foreach ( self::MCP_CLIENTS as $basename => $entry ) {
100 if ( is_plugin_active( $basename ) ) {
101 $active[ $basename ] = $entry;
102 }
103 }
104
105 return $active;
106 }
107
108 /**
109 * Abilities Pro provides and Lite does not.
110 *
111 * These are NOT registered - an assistant's capability list holds only
112 * what it can use, and an ability that exists solely to fail reads as a
113 * broken plugin. This list exists for intercept_gated_ability() below,
114 * which serves a different client: one holding an ability list cached
115 * from a Pro site, or a human pasting a Pro tutorial's slug into an
116 * assistant running against this Lite site.
117 *
118 * @since 1.8.13
119 * @var string[]
120 */
121 const GATED_ABILITIES = array(
122 'charitable/get-campaign-donation-form',
123 'charitable/update-campaign-donation-form',
124 'charitable/add-campaign-donation-form-field',
125 'charitable/get-donor',
126 'charitable/get-donor-donations',
127 'charitable/list-donors',
128 );
129
130 /**
131 * Domain registrars.
132 *
133 * Held on the instance deliberately: the registered execute callbacks bind
134 * array( $registrar, 'execute_*' ), so the registrar objects must outlive
135 * register_abilities() or those callbacks point at collected objects.
136 *
137 * @since 1.8.13
138 * @var Charitable_Abilities_Registrar[]
139 */
140 private $registrars = array();
141
142 /**
143 * Returns and/or creates the single instance of this class.
144 *
145 * @since 1.8.13
146 *
147 * @return Charitable_Abilities
148 */
149 public static function get_instance() {
150 if ( is_null( self::$instance ) ) {
151 self::$instance = new self();
152 }
153
154 return self::$instance;
155 }
156
157 /**
158 * Register the `charitable` ability category.
159 *
160 * @since 1.8.13
161 *
162 * @return void
163 */
164 public function register_category() {
165 if ( ! function_exists( 'wp_register_ability_category' ) ) {
166 return;
167 }
168
169 /**
170 * Filters whether Charitable registers its abilities at all.
171 *
172 * A one-line mitigation for a site where another plugin's REST
173 * hardening interacts badly with wp-abilities/v1, so support never
174 * has to say "deactivate Charitable".
175 *
176 * @since 1.8.13
177 *
178 * @param bool $enabled Defaults to true.
179 */
180 if ( ! apply_filters( 'charitable_abilities_enabled', true ) ) {
181 return;
182 }
183
184 wp_register_ability_category(
185 'charitable',
186 array(
187 'label' => __( 'Charitable', 'charitable' ),
188
189 /*
190 * Differs from Pro's on purpose. Core exposes category label
191 * and description over wp-abilities/v1, so a client
192 * enumerating categories - a call that costs nothing and
193 * needs no ability call - learns the edition boundary
194 * before ever trying a gated slug.
195 *
196 * @since 1.8.13
197 */
198 'description' => __( 'Read and manage fundraising campaigns, donations and reports in Charitable. Donor management and donation form editing require Charitable Pro.', 'charitable' ),
199 )
200 );
201 }
202
203 /**
204 * Instantiate every registrar and register its abilities.
205 *
206 * `wp_abilities_api_init` fires exactly once in a normal WordPress
207 * request (WP_Abilities_Registry::get_instance() guards that with
208 * its own private static instance), so the check below never runs
209 * more than once there. It matters when the hook fires more than
210 * once in the SAME process, which the Charitable and WP core test
211 * suites both do deliberately — to test kill-switch behaviour, or to
212 * register an additional test-only ability on a hook that already
213 * fired. A naive second run would ask WP_Abilities_Registry to
214 * register every charitable/* ability a second time, and core
215 * refuses each one with an "already registered" _doing_it_wrong().
216 *
217 * Checking the registry's OWN current content, not a static "have I
218 * ever run" flag: a flag cannot tell "the hook fired again on top of
219 * an unreset registry" (skip — every charitable/* ability is already
220 * there) apart from "the hook fired again because something reset
221 * the registry itself, e.g. via reflection on
222 * WP_Abilities_Registry::$instance" (register — the registry
223 * genuinely has nothing in it now, including no Charitable
224 * abilities). A flag answers the same stale "yes" to both.
225 *
226 * ORDER MATTERS: this guard runs AFTER the charitable_abilities_enabled
227 * kill-switch check below, not before it. test-abilities-contract.php's
228 * test_the_kill_switch_prevents_registration() re-fires this hook with
229 * the kill switch forced off and asserts the ability count does not
230 * change. If this guard ran first, it would return before the kill
231 * switch filter is ever consulted — the test would still pass (nothing
232 * changes either way) but for the wrong reason, and it would no longer
233 * be able to catch a real regression in the kill-switch check itself.
234 *
235 * @since 1.8.13
236 *
237 * @return void
238 */
239 public function register_abilities() {
240 if ( ! function_exists( 'wp_register_ability' ) ) {
241 return;
242 }
243
244 /**
245 * Filters whether Charitable registers its abilities at all.
246 *
247 * A one-line mitigation for a site where another plugin's REST
248 * hardening interacts badly with wp-abilities/v1, so support never
249 * has to say "deactivate Charitable".
250 *
251 * @since 1.8.13
252 *
253 * @param bool $enabled Defaults to true.
254 */
255 if ( ! apply_filters( 'charitable_abilities_enabled', true ) ) {
256 return;
257 }
258
259 foreach ( wp_get_abilities() as $registered_ability ) {
260 if ( 0 === strpos( $registered_ability->get_name(), 'charitable/' ) ) {
261 return;
262 }
263 }
264
265 /*
266 * Four registrars, not Pro's five. Donor abilities are Pro-gated in
267 * Lite: Lite's Charitable_Donor lacks six of the eleven methods they
268 * call, and Lite's Donors admin page is an education page rather than
269 * donor management. See the 1.8.13 spec, section 3.2.
270 */
271 $this->registrars = array();
272
273 /*
274 * class_exists() guarded, unlike Pro. Pro ships all five registrars in
275 * one release, so this array can never reference a class that is not
276 * yet on disk. This port is landing across several commits instead
277 * (this class first, then the registrars themselves), so for the
278 * window between this commit and the last of those, the classes below
279 * genuinely do not exist yet — and `new` on a name with no class and
280 * no autoloader match is a fatal `Error`, not a WP_Error, thrown
281 * straight out of this wp_abilities_api_init callback with nothing
282 * upstream to catch it. That would fatal every front-end, REST and
283 * WP-CLI request on any WordPress 6.9+ site running this exact
284 * commit. The guard costs nothing once every registrar exists: four
285 * classes that are always loaded are always instantiated, in the same
286 * order, and this array ends up identical to an unguarded literal.
287 *
288 * @since 1.8.13
289 */
290 foreach ( array(
291 'Charitable_Diagnostic_Abilities',
292 'Charitable_Campaign_Abilities',
293 'Charitable_Donation_Abilities',
294 'Charitable_Report_Abilities',
295 ) as $registrar_class ) {
296 if ( class_exists( $registrar_class ) ) {
297 $this->registrars[] = new $registrar_class();
298 }
299 }
300
301 foreach ( $this->registrars as $registrar ) {
302 $registrar->register();
303 }
304 }
305
306 /**
307 * Add the `abilities` log type so ability activity is filterable in Tools > Logs.
308 *
309 * @since 1.8.13
310 *
311 * @param array $types Registered log types.
312 * @return array
313 */
314 public function register_log_type( $types ) {
315 $types['abilities'] = __( 'Abilities', 'charitable' );
316
317 return $types;
318 }
319
320 /**
321 * Add the two abilities log sources so Tools > Logs can label and filter them.
322 *
323 * Charitable_Abilities_Registrar::log_write_refused() writes
324 * `source => 'write-gate'` and log_denial() writes
325 * `source => 'permission'` — the discriminator the AI Activity panel
326 * reads to tell a refusal from a denial. Charitable_Log::get_sources()
327 * is an allowlist and Charitable_Log_List_Table::column_source() falls
328 * back to the RAW KEY for anything missing from it, so without this
329 * registration the Source column reads the hyphenated machine slug
330 * `write-gate` while neighbouring abilities rows read "Core". Worse, the
331 * Source filter `<select>` is built from the same allowlist, so neither
332 * slug would be selectable and choosing "Core" would silently exclude
333 * every refusal and denial — on the exact screen the AI Activity panel
334 * links out to.
335 *
336 * Both labels are prefixed "AI" deliberately: this column is shared with
337 * the payment gateways, where a bare "Permission" would be ambiguous.
338 *
339 * Lite-only new code. Pro 1.8.18 logs both of those calls as
340 * `source => 'core'` and has no equivalent registration, so there is
341 * nothing to keep in sync until Pro adopts the two source values.
342 *
343 * @since 1.8.13
344 *
345 * @param array $sources Registered log sources, slug => label.
346 * @return array
347 */
348 public function register_log_sources( $sources ) {
349 $sources['write-gate'] = __( 'AI Write Gate', 'charitable' );
350 $sources['permission'] = __( 'AI Permission', 'charitable' );
351
352 return $sources;
353 }
354
355 /**
356 * Observe every ability invocation, to log denials and opted-in reads.
357 *
358 * Hooked to core's `wp_ability_invoked`, which fires once per execute()
359 * call before any processing. That is the only correct seam for this:
360 *
361 * - Logging a denial from inside a permission callback would fire on
362 * LISTING, not on use. Core calls permission callbacks on their own to
363 * decide which abilities a user may see, and the REST list route does
364 * it for every registered ability on every request.
365 * - Core has no denial-specific action. execute() returns a WP_Error
366 * from its permission branch before `wp_before_execute_ability` runs,
367 * so there is nothing later to hook.
368 *
369 * Writes log themselves, from inside their own execute callbacks, where
370 * the campaign/donation/donor ids are known. This only adds the two
371 * cases they cannot see: a call that never reached them, and a read.
372 *
373 * @since 1.8.13
374 *
375 * @param string $ability_name The ability being invoked.
376 * @param mixed $input The raw input.
377 * @param WP_Ability $ability The ability instance.
378 * @return void
379 */
380 public function log_ability_invocation( $ability_name, $input, $ability ) {
381 if ( 0 !== strpos( (string) $ability_name, 'charitable/' ) ) {
382 return;
383 }
384
385 /*
386 * Counted before the permission check, and before the object guard
387 * below, because this is the only seam that sees EVERY call. The
388 * counter's whole purpose is to be able to say "nothing has ever
389 * called this", so a call that is about to be denied, or that
390 * arrives with an ability object we cannot inspect, still counts as
391 * a call.
392 */
393 Charitable_Abilities_Usage::record_invocation( $ability_name );
394
395 if ( ! is_object( $ability ) || ! method_exists( $ability, 'check_permissions' ) ) {
396 return;
397 }
398
399 $permitted = $ability->check_permissions( $input );
400
401 if ( is_wp_error( $permitted ) || ! $permitted ) {
402 Charitable_Abilities_Usage::record_denial( $ability_name );
403 Charitable_Abilities_Registrar::log_denial( $ability_name );
404
405 return;
406 }
407
408 $meta = method_exists( $ability, 'get_meta' ) ? (array) $ability->get_meta() : array();
409
410 if ( ! empty( $meta['annotations']['readonly'] ) ) {
411 Charitable_Abilities_Registrar::log_read( $ability_name );
412 }
413 }
414
415 /**
416 * Turn core's "ability not found" into an honest "needs Pro", for the six.
417 *
418 * Core's run controller (class-wp-rest-abilities-v1-run-controller.php)
419 * answers an unregistered ability name with `rest_ability_not_found` and
420 * `array( 'status' => 404 )` ALREADY SET. set_error_http_status() below
421 * then ignores it twice over: it returns early unless the code starts
422 * with `charitable_`, and separately returns any error that already
423 * carries a status. Since the six GATED_ABILITIES are never registered,
424 * no Charitable code ever runs for them - so without this interceptor,
425 * nothing would ever construct `charitable_capability_unavailable` and
426 * the 422 has no emitter.
427 *
428 * Runs at priority 9, before set_error_http_status() at 10 - see
429 * charitable-abilities-hooks.php. It must run first: by the time
430 * set_error_http_status() sees this error, the status is already set to
431 * 404 by core, and that method's own "never override a status something
432 * upstream set deliberately" guard would leave it there.
433 *
434 * Scoped to the six KNOWN slugs on purpose, not to every unknown
435 * ability name. A genuinely mistyped slug - `charitable/get-donr` -
436 * still deserves core's 404: an assistant that typo'd needs "no such
437 * ability", not "buy Pro".
438 *
439 * @since 1.8.13
440 *
441 * @param mixed $response Result of the request handler.
442 * @param array $handler Route handler used for the request.
443 * @param WP_REST_Request $request The request.
444 * @return mixed
445 */
446 public function intercept_gated_ability( $response, $handler, $request ) {
447 if ( ! is_wp_error( $response ) ) {
448 return $response;
449 }
450
451 if ( 'rest_ability_not_found' !== $response->get_error_code() ) {
452 return $response;
453 }
454
455 $route = (string) $request->get_route();
456
457 if ( 0 !== strpos( $route, '/wp-abilities/' ) ) {
458 return $response;
459 }
460
461 /*
462 * Read the parsed `name` param, never re-derive it from the route
463 * string. There is NO `~` or other namespace-separator encoding
464 * anywhere in core's REST API (verified: zero hits for `~` across
465 * wp-includes/rest-api/) - the run controller's own route regex,
466 * `(?P<name>[a-zA-Z0-9\-\/]+?)/run` (class-wp-rest-abilities-v1-run-
467 * controller.php), matches a LITERAL slash, so `charitable/get-donor`
468 * arrives on the wire exactly as written, slash and all.
469 *
470 * WP_REST_Server::match_request_to_handler() already calls
471 * `$request->set_url_params( $args )` with that regex capture before
472 * ANY `rest_request_after_callbacks` filter runs (class-wp-rest-
473 * server.php), so the full, untruncated slug is already sitting on
474 * the request under the `name` param by the time this method sees
475 * it. An earlier version of this method re-parsed the route string
476 * with `#/abilities/([^/]+)#`, which stops at the first slash and
477 * truncates every multi-segment slug to `"charitable"` - silently
478 * disabling the 422 for every real request, since `"charitable"` is
479 * never a member of GATED_ABILITIES. Do not reintroduce that regex.
480 */
481 $requested = (string) $request->get_param( 'name' );
482
483 if ( ! in_array( $requested, self::GATED_ABILITIES, true ) ) {
484 return $response;
485 }
486
487 return new WP_Error(
488 'charitable_capability_unavailable',
489 __( 'That operation requires Charitable Pro and is not available on this site. Call Get Charitable Plugin Info for the list of capabilities this install does not provide.', 'charitable' ),
490 array( 'status' => 422 )
491 );
492 }
493
494 /**
495 * Give ability errors an accurate HTTP status over wp-abilities/v1.
496 *
497 * Without this EVERY error from EVERY Charitable ability comes back as
498 * HTTP 500. Verified live across the whole surface before this was
499 * written: campaign-not-found, donation-not-found, donor-not-found, an
500 * inverted date range and a rejected write all returned 500.
501 *
502 * The cause is the interaction between two correct things.
503 * rest_convert_error_to_response() (rest-api.php:3464) reads the status
504 * from the WP_Error's DATA, defaulting to 500 when none is present, and
505 * C7 requires the data payload to be dropped at the ability boundary
506 * because upstream error data can carry licence keys, admin email and
507 * gateway envelopes. Scrubbed errors therefore arrive status-less.
508 *
509 * A 500 is not a cosmetic difference to an AI client: it means "the
510 * server broke", so an agent retries it. 404 and 400 mean "your request
511 * was wrong", so an agent corrects itself or tells the user. Reporting
512 * a mistyped campaign id as a server fault is the difference between an
513 * assistant asking which campaign you meant and one insisting the site
514 * is down.
515 *
516 * Applied here, once, rather than at each of the ~60 places an ability
517 * builds a WP_Error: a per-site fix cannot be complete and the next
518 * ability added would miss it. Only the status is added — the dropped
519 * data payload stays dropped, so C7 still holds.
520 *
521 * @since 1.8.13
522 *
523 * @param mixed $response Result of the request handler.
524 * @param array $handler Route handler used for the request.
525 * @param WP_REST_Request $request Request used to generate the response.
526 * @return mixed
527 */
528 public function set_error_http_status( $response, $handler, $request ) {
529 if ( ! is_wp_error( $response ) ) {
530 return $response;
531 }
532
533 if ( 0 !== strpos( (string) $request->get_route(), '/wp-abilities/' ) ) {
534 return $response;
535 }
536
537 $code = $response->get_error_code();
538
539 if ( 0 !== strpos( (string) $code, 'charitable_' ) ) {
540 return $response;
541 }
542
543 $data = $response->get_error_data();
544
545 /* Never override a status something upstream set deliberately. */
546 if ( is_array( $data ) && isset( $data['status'] ) ) {
547 return $response;
548 }
549
550 $status = 400;
551
552 if ( false !== strpos( (string) $code, '_not_found' ) ) {
553 $status = 404;
554 } elseif ( false !== strpos( (string) $code, 'permission' ) || false !== strpos( (string) $code, 'denied' ) ) {
555 $status = 403;
556 }
557
558 $scrubbed = new WP_Error( $code, $response->get_error_message(), array( 'status' => $status ) );
559
560 return $scrubbed;
561 }
562 }
563
564 endif;
565