| 1 |
<?php |
| 2 |
/** |
| 3 |
* Base class for Charitable ability registrars. |
| 4 |
* |
| 5 |
* Supplies the shared permission callbacks, input sanitising, money helpers, |
| 6 |
* error scrubbing and logging so subclasses only describe schema and behaviour. |
| 7 |
* |
| 8 |
* @package Charitable/Abstracts/Charitable_Abilities_Registrar |
| 9 |
* @copyright Copyright (c) 2026, WP Charitable LLC |
| 10 |
* @license http://opensource.org/licenses/gpl-2.0.php GNU Public License |
| 11 |
* @since 1.8.13 |
| 12 |
* @version 1.8.13 |
| 13 |
*/ |
| 14 |
|
| 15 |
if ( ! defined( 'ABSPATH' ) ) { |
| 16 |
exit; |
| 17 |
} |
| 18 |
|
| 19 |
if ( ! class_exists( 'Charitable_Abilities_Registrar' ) ) : |
| 20 |
|
| 21 |
/** |
| 22 |
* Charitable_Abilities_Registrar |
| 23 |
* |
| 24 |
* @since 1.8.13 |
| 25 |
*/ |
| 26 |
abstract class Charitable_Abilities_Registrar { |
| 27 |
|
| 28 |
/** |
| 29 |
* The ability category all Charitable abilities belong to. |
| 30 |
* |
| 31 |
* @since 1.8.13 |
| 32 |
*/ |
| 33 |
const CATEGORY = 'charitable'; |
| 34 |
|
| 35 |
/** |
| 36 |
* Register every ability owned by this registrar. |
| 37 |
* |
| 38 |
* @since 1.8.13 |
| 39 |
* |
| 40 |
* @return void |
| 41 |
*/ |
| 42 |
abstract public function register(); |
| 43 |
|
| 44 |
/** |
| 45 |
* Register an ability with Charitable's shared defaults applied. |
| 46 |
* |
| 47 |
* Defaults: category `charitable`, and **both** `meta.public` and |
| 48 |
* `meta.show_in_rest` set to true. Setting both is not belt-and-braces; |
| 49 |
* it is required, and setting only `public` shipped a bug. |
| 50 |
* |
| 51 |
* WordPress 7.1 resolves `show_in_rest` from most specific to least: |
| 52 |
* an explicit `show_in_rest` wins, otherwise the high-level `public` |
| 53 |
* flag seeds it (class-wp-ability.php:366-367). **WordPress 7.0.x does |
| 54 |
* not do that second step at all** — it reads `meta['show_in_rest']` |
| 55 |
* and defaults it to `self::DEFAULT_SHOW_IN_REST`, which is false. |
| 56 |
* `public` is simply ignored there. |
| 57 |
* |
| 58 |
* So an ability registered with only `public => true` is invisible over |
| 59 |
* `wp-abilities/v1` on 7.0.x. Verified on a live WordPress 7.0.2 site: |
| 60 |
* all 28 Charitable abilities registered in PHP, and the REST list route |
| 61 |
* returned 21 — ours absent, `show_in_rest` resolved to **false**. |
| 62 |
* PushEngage's appeared because they set `show_in_rest` explicitly. |
| 63 |
* |
| 64 |
* That is precisely the Formidable failure mode this whole feature |
| 65 |
* exists to avoid: abilities that register but are invisible to the |
| 66 |
* install base. And the test suite could not catch it, because the test |
| 67 |
* environment runs 7.1, where `public` alone works. |
| 68 |
* |
| 69 |
* Accepts a top-level `annotations` key for convenience and relocates it |
| 70 |
* to `meta.annotations`, which is where WP_Ability expects it. |
| 71 |
* |
| 72 |
* @since 1.8.13 |
| 73 |
* |
| 74 |
* @param string $slug Ability slug, e.g. "charitable/list-campaigns". |
| 75 |
* @param array $args Ability config; see wp_register_ability(). |
| 76 |
* @return void |
| 77 |
*/ |
| 78 |
protected function register_ability( $slug, array $args ) { |
| 79 |
$meta = self::default_meta(); |
| 80 |
|
| 81 |
if ( isset( $args['annotations'] ) ) { |
| 82 |
$meta['annotations'] = $args['annotations']; |
| 83 |
unset( $args['annotations'] ); |
| 84 |
} |
| 85 |
|
| 86 |
if ( isset( $args['meta'] ) && is_array( $args['meta'] ) ) { |
| 87 |
$meta = array_merge( $meta, $args['meta'] ); |
| 88 |
} |
| 89 |
|
| 90 |
$args['meta'] = $meta; |
| 91 |
$args['category'] = self::CATEGORY; |
| 92 |
|
| 93 |
/** |
| 94 |
* Filters a Charitable ability's meta array before registration. |
| 95 |
* |
| 96 |
* The Abilities API `meta` array is free-form, and different MCP |
| 97 |
* clients read different keys out of it. Charitable sets `public` |
| 98 |
* and `show_in_rest` — the WordPress-standard exposure signals that |
| 99 |
* the mainstream bridges (WordPress MCP Adapter, wordpress-mcp, |
| 100 |
* WPVibe) surface abilities from — plus any `annotations`. A |
| 101 |
* third-party MCP layer may instead gate its discovery on its own |
| 102 |
* key, e.g. `meta['mcp']['public']`. This filter lets a site add |
| 103 |
* whatever such a client expects without editing the plugin, and |
| 104 |
* without Charitable having to track each vendor's convention in |
| 105 |
* core. |
| 106 |
* |
| 107 |
* The AI write gate is decided from the unfiltered annotations |
| 108 |
* above, so a filter cannot loosen it: adding `annotations.readonly` |
| 109 |
* here changes only the advertised meta, never whether a write |
| 110 |
* actually executes. |
| 111 |
* |
| 112 |
* @since 1.8.13 |
| 113 |
* |
| 114 |
* @param array $meta The assembled meta: default_meta() plus this |
| 115 |
* ability's annotations and any meta overrides. |
| 116 |
* @param string $slug The ability slug, e.g. "charitable/list-campaigns". |
| 117 |
* @param array $args The full registration args, for context. |
| 118 |
*/ |
| 119 |
$args['meta'] = (array) apply_filters( 'charitable_ability_meta', $args['meta'], $slug, $args ); |
| 120 |
|
| 121 |
/* |
| 122 |
* A callback that is not callable fails SILENTLY, and that is the whole |
| 123 |
* reason for this guard. WordPress drops an ability whose |
| 124 |
* permission_callback cannot be invoked from the wp-abilities/v1 list |
| 125 |
* with no error at registration, none in the response, and none in the |
| 126 |
* log — the ability is simply absent. |
| 127 |
* |
| 128 |
* Found by deploying one file of a two-file change: the campaign |
| 129 |
* registrar shipped referencing permission_read_campaign() and |
| 130 |
* permission_edit_campaign() while the abstract registrar defining them |
| 131 |
* did not, and discover_abilities returned 20 Charitable abilities where |
| 132 |
* 29 were registered. Same failure shape as the show_in_rest defect: |
| 133 |
* registered in PHP, invisible over REST, healthy-looking count on the |
| 134 |
* wrong channel. |
| 135 |
* |
| 136 |
* The ability is still registered rather than skipped, deliberately. |
| 137 |
* Skipping would quietly drop the registered count to match the visible |
| 138 |
* one and hide the discrepancy; registering leaves |
| 139 |
* get-site-environment's registered_count and rest_visible_count |
| 140 |
* disagreeing, which is the signal that survives into production — |
| 141 |
* _doing_it_wrong() is silent without WP_DEBUG. |
| 142 |
*/ |
| 143 |
foreach ( array( 'permission_callback', 'execute_callback' ) as $callback_key ) { |
| 144 |
if ( isset( $args[ $callback_key ] ) && ! is_callable( $args[ $callback_key ] ) ) { |
| 145 |
_doing_it_wrong( |
| 146 |
'Charitable_Abilities_Registrar::register_ability', |
| 147 |
sprintf( |
| 148 |
/* translators: 1: the ability slug, 2: the callback argument name. */ |
| 149 |
esc_html__( 'Ability "%1$s" was registered with a %2$s that is not callable, so WordPress will hide it from the abilities REST route without reporting an error. Check that the method exists and that every file of the change was deployed.', 'charitable' ), |
| 150 |
esc_html( $slug ), |
| 151 |
esc_html( $callback_key ) |
| 152 |
), |
| 153 |
'1.8.13' |
| 154 |
); |
| 155 |
} |
| 156 |
} |
| 157 |
|
| 158 |
/* |
| 159 |
* The AI write gate. Wrapped HERE, deliberately AFTER the callable guard |
| 160 |
* above: wrapping first would hide a non-callable original inside the |
| 161 |
* closure, the guard would see a perfectly callable Closure, and the |
| 162 |
* silent-drop defect that guard exists to catch would come straight back. |
| 163 |
* |
| 164 |
* Gated by the `readonly` annotation rather than a list of ability slugs, |
| 165 |
* so an ability registered without `readonly => true` is gated the moment |
| 166 |
* it is added. A hand-maintained list of the nine current writes would |
| 167 |
* silently miss the tenth. |
| 168 |
* |
| 169 |
* The setting is read inside the closure, at EXECUTION time, so flipping |
| 170 |
* the toggle takes effect on the next request without anything |
| 171 |
* re-registering. |
| 172 |
* |
| 173 |
* @since 1.8.13 |
| 174 |
*/ |
| 175 |
if ( empty( $meta['annotations']['readonly'] ) && isset( $args['execute_callback'] ) ) { |
| 176 |
$args['execute_callback'] = self::gate_write_callback( $slug, $args['execute_callback'] ); |
| 177 |
} |
| 178 |
|
| 179 |
if ( isset( $args['input_schema'] ) && is_array( $args['input_schema'] ) ) { |
| 180 |
$args['input_schema'] = self::allow_absent_input( $args['input_schema'] ); |
| 181 |
} |
| 182 |
|
| 183 |
wp_register_ability( $slug, $args ); |
| 184 |
} |
| 185 |
|
| 186 |
/** |
| 187 |
* Let an ability that REQUIRES nothing be called with nothing. |
| 188 |
* |
| 189 |
* Widens `type: object` to `type: [object, null]` on any input schema |
| 190 |
* that declares no required properties. One place decides this, applied |
| 191 |
* to every ability at registration, so no individual schema has to |
| 192 |
* remember - and a schema added later gets it for free. |
| 193 |
* |
| 194 |
* ── WHY ────────────────────────────────────────────────────────────── |
| 195 |
* |
| 196 |
* Core validates a missing input envelope as NULL, and `type: object` |
| 197 |
* fails it with 400 `ability_invalid_input`, "input is not of type |
| 198 |
* object". For a READONLY ability that is fatal rather than merely |
| 199 |
* unfriendly: core answers POST with 405 `rest_ability_invalid_method`, |
| 200 |
* so GET is the only method it accepts, a GET carries no body, and the |
| 201 |
* only call it permits was the only call it refused. All six abilities |
| 202 |
* registered with no_input_schema() were unreachable over REST - |
| 203 |
* including get-plugin-info, the orientation call an assistant is told |
| 204 |
* to make first. |
| 205 |
* |
| 206 |
* Reported from 1.8.13 testing as "a read-only ability that takes no |
| 207 |
* settings shouldn't reject a plain GET with no input", and confirmed |
| 208 |
* over rest_do_request() before this changed. |
| 209 |
* |
| 210 |
* ── WHY IT IS SCOPED TO "NO REQUIRED PROPERTIES" ───────────────────── |
| 211 |
* |
| 212 |
* An ability that genuinely needs an argument must still fail, and fail |
| 213 |
* on the missing ARGUMENT rather than being handed null and improvising. |
| 214 |
* get-campaign without a campaign_id is a client bug and should say so. |
| 215 |
* So `required` being present and non-empty leaves the schema alone. |
| 216 |
* |
| 217 |
* `additionalProperties` is never touched, so this widens what counts as |
| 218 |
* "nothing sent" and never what counts as valid input. |
| 219 |
* |
| 220 |
* ── THE CONSEQUENCE, WRITTEN DOWN ──────────────────────────────────── |
| 221 |
* |
| 222 |
* This deliberately diverges from core's uniform behaviour, which |
| 223 |
* test-abilities-rest-channel.php had recorded as a client contract: |
| 224 |
* "always send an input envelope". After this, that guidance is only |
| 225 |
* needed for abilities with required properties. Any client-facing |
| 226 |
* documentation of the envelope rule needs to say so. |
| 227 |
* |
| 228 |
* @since 1.8.13 |
| 229 |
* |
| 230 |
* @param array $schema An ability's input schema. |
| 231 |
* @return array The schema, widened if it requires nothing. |
| 232 |
*/ |
| 233 |
private static function allow_absent_input( array $schema ) { |
| 234 |
|
| 235 |
if ( ! isset( $schema['type'] ) || 'object' !== $schema['type'] ) { |
| 236 |
return $schema; |
| 237 |
} |
| 238 |
|
| 239 |
if ( ! empty( $schema['required'] ) ) { |
| 240 |
return $schema; |
| 241 |
} |
| 242 |
|
| 243 |
$schema['type'] = array( 'object', 'null' ); |
| 244 |
|
| 245 |
return $schema; |
| 246 |
} |
| 247 |
|
| 248 |
/** |
| 249 |
* Wrap a write ability's callback so it refuses when AI writes are off. |
| 250 |
* |
| 251 |
* Refuses BEFORE the callback runs, so nothing is read, validated or |
| 252 |
* written on a gated request. |
| 253 |
* |
| 254 |
* Asks self::write_enabled(), the one master switch. 1.8.13 briefly |
| 255 |
* asked a per-consequence scope instead; that was reverted before release |
| 256 |
* in favour of matching Pro, and write_enabled()'s docblock records why. |
| 257 |
* |
| 258 |
* Note this is refuse-only: the ability stays visible in |
| 259 |
* `/wp-abilities/v1/abilities` and still reports itself to a client. That |
| 260 |
* is a deliberate choice over hiding it, so the operation count a site |
| 261 |
* reports does not change with a setting — the changelog says twenty-four |
| 262 |
* and the e2e floor asserts 24, and they stay true whichever way the |
| 263 |
* switch is set. System Info and the Tools > AI MCP tab COMPUTE their |
| 264 |
* counts at request time rather than stating a number, so they follow |
| 265 |
* the registry automatically. |
| 266 |
* The trade is that an assistant may offer a write and then be refused, |
| 267 |
* which is why the message below has to explain itself properly. |
| 268 |
* |
| 269 |
* @since 1.8.13 |
| 270 |
* |
| 271 |
* @param string $slug The ability slug, for the refusal log entry. |
| 272 |
* @param callable $callback The ability's real execute callback. |
| 273 |
* @return Closure |
| 274 |
*/ |
| 275 |
private static function gate_write_callback( $slug, $callback ) { |
| 276 |
|
| 277 |
return function ( ...$params ) use ( $callback, $slug ) { |
| 278 |
|
| 279 |
if ( ! self::write_enabled() ) { |
| 280 |
Charitable_Abilities_Usage::record_refusal( $slug ); |
| 281 |
|
| 282 |
/* |
| 283 |
* The LOG row stays generic on purpose. log_write_refused() |
| 284 |
* takes a slug and writes a fixed message, and the AI Activity |
| 285 |
* panel classifies refusals against `source => 'write-gate'` |
| 286 |
* rather than message text. |
| 287 |
*/ |
| 288 |
self::log_write_refused( $slug ); |
| 289 |
|
| 290 |
return new WP_Error( |
| 291 |
'charitable_write_access_disabled', |
| 292 |
__( 'AI write access is turned off for this site, so this cannot be changed. An administrator can turn it on under Charitable > Tools > AI MCP. Reading campaigns, donations and reports works either way.', 'charitable' ), |
| 293 |
array( 'status' => 403 ) |
| 294 |
); |
| 295 |
} |
| 296 |
|
| 297 |
return call_user_func_array( $callback, $params ); |
| 298 |
}; |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* Whether AI write access is on for this site. THIS IS THE WRITE GATE. |
| 303 |
* |
| 304 |
* One master switch, the same model Charitable Pro uses and the same key |
| 305 |
* both editions read: `ai_mcp_write_enabled` in the shared |
| 306 |
* charitable_settings row. Reads are never gated - only abilities that |
| 307 |
* carry no `readonly` annotation reach the gate at all. |
| 308 |
* |
| 309 |
* NOT the only check a write passes. Every ability still runs its own |
| 310 |
* permission_callback against the WordPress capability it always |
| 311 |
* required, so an assistant acting as a user who cannot edit campaigns |
| 312 |
* still cannot, however this switch is set. |
| 313 |
* |
| 314 |
* 1.8.13 briefly split this three ways by consequence, behind a |
| 315 |
* Charitable_Abilities_Scopes class, an `ai_mcp_write_scopes` option and |
| 316 |
* a second `charitable_abilities_allow_write_scope` filter. Reverted |
| 317 |
* before release in favour of matching Pro: three switches meant three |
| 318 |
* ways to be half-on, a refusal that had to name which one applied, and a |
| 319 |
* legacy boolean kept in AND agreement with the map so a partial Lite |
| 320 |
* grant could not open all nine of Pro's writes on upgrade. Removed |
| 321 |
* rather than deprecated, because no released version exposed them. |
| 322 |
* |
| 323 |
* What protects the irreversible operations is unaffected and predates |
| 324 |
* the split: an assistant must confirm explicitly before trashing a |
| 325 |
* campaign that has donations, or taking a live donation-receiving |
| 326 |
* campaign offline. |
| 327 |
* |
| 328 |
* @since 1.8.13 |
| 329 |
* |
| 330 |
* @return bool Whether an AI assistant may make changes on this site. |
| 331 |
*/ |
| 332 |
public static function write_enabled() { |
| 333 |
|
| 334 |
$default = ! empty( charitable_get_option( 'ai_mcp_write_enabled', '' ) ); |
| 335 |
|
| 336 |
/** |
| 337 |
* Filters whether an AI assistant may make changes on this site. |
| 338 |
* |
| 339 |
* A real switch in both directions: `__return_false` refuses every |
| 340 |
* write, `__return_true` allows them, subject as always to each |
| 341 |
* ability's own WordPress capability check. Applied at exactly ONE |
| 342 |
* call site, which is this one. |
| 343 |
* |
| 344 |
* Both of those were briefly untrue: 1.8.13's scoped permissions made |
| 345 |
* this a veto only, and applied the same hook name at two call sites |
| 346 |
* with different defaults. Reverting to one switch restores the |
| 347 |
* behaviour Pro's changelog documents. |
| 348 |
* |
| 349 |
* @since 1.8.13 |
| 350 |
* |
| 351 |
* @param bool $enabled Defaults to the `ai_mcp_write_enabled` setting, |
| 352 |
* which is OFF on a site that has never set it. |
| 353 |
*/ |
| 354 |
return (bool) apply_filters( 'charitable_abilities_allow_write', $default ); |
| 355 |
} |
| 356 |
|
| 357 |
/** |
| 358 |
* The REST-visibility meta every Charitable ability registers with. |
| 359 |
* |
| 360 |
* Factored out of register_ability() so it can be asserted directly. |
| 361 |
* That is not tidiness — it is the only way to test this correctly. |
| 362 |
* Reading a REGISTERED ability's `get_meta()` cannot tell you what we |
| 363 |
* passed, because WordPress 7.1 seeds `show_in_rest` from `public` |
| 364 |
* itself; on 7.1 the resolved meta says `show_in_rest => true` whether |
| 365 |
* we set it or not. A test against the registry therefore passes even |
| 366 |
* with the bug present, which is exactly how the bug shipped. Asserting |
| 367 |
* this method's return value asserts OUR contribution. |
| 368 |
* |
| 369 |
* @since 1.8.13 |
| 370 |
* |
| 371 |
* @return array |
| 372 |
*/ |
| 373 |
protected static function default_meta() { |
| 374 |
return array( |
| 375 |
|
| 376 |
/* |
| 377 |
* `public` is the documented high-level flag on 7.1, and |
| 378 |
* `show_in_rest` is what 7.0.x actually reads. BOTH are needed: |
| 379 |
* 7.0.x ignores `public` and defaults `show_in_rest` to false, |
| 380 |
* so `public` alone hides the ability from every MCP client on |
| 381 |
* the current WordPress release. An explicit `show_in_rest` |
| 382 |
* wins over `public` on 7.1, so setting both is consistent |
| 383 |
* rather than contradictory. |
| 384 |
*/ |
| 385 |
'public' => true, |
| 386 |
'show_in_rest' => true, |
| 387 |
|
| 388 |
/* |
| 389 |
* Some MCP layers gate their tool discovery on a nested |
| 390 |
* `meta['mcp']['public']` flag rather than the top-level `public` |
| 391 |
* above. Our sibling plugin WPForms sets this same key, and |
| 392 |
* WPVibe reads it, so it is the de-facto convention across the |
| 393 |
* AM/WPVibe MCP stack even though core does not define it (the |
| 394 |
* meta array is free-form). Set it on every ability so |
| 395 |
* Charitable's operations are discoverable out of the box by |
| 396 |
* those clients rather than only by ones that read `public`. |
| 397 |
* |
| 398 |
* This affects DISCOVERY only, and deliberately does NOT copy |
| 399 |
* WPForms' model of hiding writes from discovery when writes are |
| 400 |
* off. Charitable keeps all abilities discoverable — the |
| 401 |
* "every registered ability is also REST-visible" invariant |
| 402 |
* that System Info, the Tools > AI MCP tab and the e2e floor |
| 403 |
* all assert stays true (24 of 24 in Lite today) — and a write is |
| 404 |
* still refused at execution time when AI write access is off, |
| 405 |
* via gate_write_callback(). A site can still override this per |
| 406 |
* ability through the `charitable_ability_meta` filter. |
| 407 |
*/ |
| 408 |
'mcp' => array( |
| 409 |
'public' => true, |
| 410 |
), |
| 411 |
); |
| 412 |
} |
| 413 |
|
| 414 |
/** |
| 415 |
* Normalise ability input, and read one integer id out of it safely. |
| 416 |
* |
| 417 |
* Every `execute_*` method has to be **public**, because core needs it |
| 418 |
* as a callback. That makes each one a reachable entry point for any |
| 419 |
* internal caller holding a registrar instance — and this codebase |
| 420 |
* already does exactly that: |
| 421 |
* `Charitable_Donor_Abilities::execute_get_donor_donations()` calls |
| 422 |
* `Charitable_Donation_Abilities::execute_list_donations()` directly, to |
| 423 |
* keep the money-safe listing logic in one place rather than duplicating |
| 424 |
* it. That call bypasses core's schema validation entirely. |
| 425 |
* |
| 426 |
* Fuzzed rather than assumed. 252 hostile calls through `execute()` |
| 427 |
* produced ZERO PHP errors — core's validator catches everything. The |
| 428 |
* same 252 made directly against the `execute_*` methods produced 83: |
| 429 |
* `null`, a bare string, an int and a `stdClass` all reached |
| 430 |
* `$input['campaign_id']` and raised "Trying to access array offset on |
| 431 |
* null", "Undefined array key" or a TypeError instead of a WP_Error. |
| 432 |
* Latent rather than live, but a public method that fatals on `null` is |
| 433 |
* the same class of landmine as |
| 434 |
* `Charitable_Campaign_Builder_Templates::get_instance()` returning the |
| 435 |
* wrong class — catalogued in this codebase as exactly that. |
| 436 |
* |
| 437 |
* Returning 0 for a missing or unusable id is the correct behaviour, not |
| 438 |
* a swallow: every `find_*()` guard rejects 0 with a "not found" |
| 439 |
* WP_Error, which is what a caller passing nothing should get back. |
| 440 |
* |
| 441 |
* @since 1.8.13 |
| 442 |
* |
| 443 |
* @param mixed $input Ability input. Anything at all. |
| 444 |
* @param string $key The key to read. |
| 445 |
* @return int |
| 446 |
*/ |
| 447 |
protected static function input_id( $input, $key ) { |
| 448 |
if ( ! is_array( $input ) || ! isset( $input[ $key ] ) || ! is_scalar( $input[ $key ] ) ) { |
| 449 |
return 0; |
| 450 |
} |
| 451 |
|
| 452 |
return (int) $input[ $key ]; |
| 453 |
} |
| 454 |
|
| 455 |
/** |
| 456 |
* Normalise ability input to an array. |
| 457 |
* |
| 458 |
* The companion to input_id() for methods that read several optional |
| 459 |
* keys. See that method's docblock for why this is needed at all. |
| 460 |
* |
| 461 |
* @since 1.8.13 |
| 462 |
* |
| 463 |
* @param mixed $input Ability input. Anything at all. |
| 464 |
* @return array |
| 465 |
*/ |
| 466 |
protected static function input_array( $input ) { |
| 467 |
return is_array( $input ) ? $input : array(); |
| 468 |
} |
| 469 |
|
| 470 |
/** |
| 471 |
* Read a bounded integer out of ability input. |
| 472 |
* |
| 473 |
* Replaces the `isset( $input[ $k ] ) ? max( 1, min( 100, (int) … ) ) : 20` |
| 474 |
* idiom, which reads safely but casts unsafely: `isset()` is TRUE for an |
| 475 |
* array or object value, so `(int) $input['per_page']` on an array still |
| 476 |
* raises "Array to int conversion" and on an object throws. The |
| 477 |
* is_scalar() check is the part that idiom was missing. |
| 478 |
* |
| 479 |
* An absent or unusable value returns $default; a supplied one is |
| 480 |
* clamped. That distinction matters: `per_page => 0` should become the |
| 481 |
* minimum of 1, not silently jump back to the default of 20. |
| 482 |
* |
| 483 |
* @since 1.8.13 |
| 484 |
* |
| 485 |
* @param mixed $input Ability input. Anything at all. |
| 486 |
* @param string $key The key to read. |
| 487 |
* @param int $default Returned when the key is absent or unusable. |
| 488 |
* @param int|null $min Optional lower bound. |
| 489 |
* @param int|null $max Optional upper bound. |
| 490 |
* @return int |
| 491 |
*/ |
| 492 |
protected static function input_int( $input, $key, $default, $min = null, $max = null ) { |
| 493 |
if ( ! is_array( $input ) || ! isset( $input[ $key ] ) || ! is_scalar( $input[ $key ] ) ) { |
| 494 |
return (int) $default; |
| 495 |
} |
| 496 |
|
| 497 |
$value = (int) $input[ $key ]; |
| 498 |
|
| 499 |
if ( null !== $min ) { |
| 500 |
$value = max( (int) $min, $value ); |
| 501 |
} |
| 502 |
|
| 503 |
if ( null !== $max ) { |
| 504 |
$value = min( (int) $max, $value ); |
| 505 |
} |
| 506 |
|
| 507 |
return $value; |
| 508 |
} |
| 509 |
|
| 510 |
/** |
| 511 |
* Read a string out of ability input. |
| 512 |
* |
| 513 |
* Same reasoning as input_int(): a non-scalar value returns the default |
| 514 |
* rather than raising "Array to string conversion" or throwing on an |
| 515 |
* object. |
| 516 |
* |
| 517 |
* @since 1.8.13 |
| 518 |
* |
| 519 |
* @param mixed $input Ability input. Anything at all. |
| 520 |
* @param string $key The key to read. |
| 521 |
* @param string $default Returned when the key is absent or unusable. |
| 522 |
* @return string |
| 523 |
*/ |
| 524 |
protected static function input_string( $input, $key, $default = '' ) { |
| 525 |
if ( ! is_array( $input ) || ! isset( $input[ $key ] ) || ! is_scalar( $input[ $key ] ) ) { |
| 526 |
return (string) $default; |
| 527 |
} |
| 528 |
|
| 529 |
return (string) $input[ $key ]; |
| 530 |
} |
| 531 |
|
| 532 |
/** |
| 533 |
* Permission callback: plugin administration and diagnostics. |
| 534 |
* |
| 535 |
* @since 1.8.13 |
| 536 |
* |
| 537 |
* @return boolean |
| 538 |
*/ |
| 539 |
public static function permission_manage() { |
| 540 |
return current_user_can( 'manage_charitable_settings' ); |
| 541 |
} |
| 542 |
|
| 543 |
/** |
| 544 |
* Permission callback: abilities that address NO PARTICULAR campaign. |
| 545 |
* |
| 546 |
* `edit_campaigns` is the plural, GENERAL capability: it carries no post |
| 547 |
* id and no author check. Use it only where there is genuinely no object |
| 548 |
* to check — listing the available builder templates, or a listing whose |
| 549 |
* own query is author-scoped. |
| 550 |
* |
| 551 |
* For anything taking a `campaign_id`, use permission_read_campaign() or |
| 552 |
* permission_edit_campaign() instead. The `campaign` post type registers |
| 553 |
* `map_meta_cap => true` (Charitable_Post_Types::register_post_types()), |
| 554 |
* so WordPress resolves the SINGULAR meta capabilities against the post's |
| 555 |
* author — which is the check wp-admin makes and this one does not. |
| 556 |
* |
| 557 |
* Getting that wrong is not theoretical. Charitable Ambassadors grants its |
| 558 |
* `campaign_creator` role exactly `read` + `edit_campaigns` and nothing |
| 559 |
* else, automatically, to any user who starts a campaign through the |
| 560 |
* front-end form. Gating a campaign_id-bearing ability on this callback |
| 561 |
* therefore lets one ambassador read and rewrite another's fundraiser. |
| 562 |
* |
| 563 |
* @since 1.8.13 |
| 564 |
* |
| 565 |
* @return boolean |
| 566 |
*/ |
| 567 |
public static function permission_campaigns() { |
| 568 |
return current_user_can( 'edit_campaigns' ); |
| 569 |
} |
| 570 |
|
| 571 |
/** |
| 572 |
* Permission callback: reading ONE campaign. |
| 573 |
* |
| 574 |
* Requires the general `edit_campaigns` capability as a floor before any |
| 575 |
* object-level check runs, so a caller holding no Charitable capability |
| 576 |
* at all is refused regardless of the target campaign's status. |
| 577 |
* |
| 578 |
* Above that floor, `read_campaign` is the meta capability that NARROWS |
| 579 |
* access, so core resolves it against the post: a published campaign is |
| 580 |
* readable for its goal and amount raised, which are already public on |
| 581 |
* its own page — `get-campaign-performance`, one of the four abilities |
| 582 |
* gated on this callback, additionally exposes donor_count and |
| 583 |
* average_gift to any `edit_campaigns` holder this way, an accepted |
| 584 |
* boundary rather than an oversight (see U4 in the audit findings doc). |
| 585 |
* A draft campaign falls through to `edit_others_campaigns`, and a |
| 586 |
* private one to `read_private_campaigns`; Ambassadors' |
| 587 |
* `campaign_creator` holds neither. |
| 588 |
* |
| 589 |
* @since 1.8.13 |
| 590 |
* |
| 591 |
* @param array|null $input Ability input. |
| 592 |
* @return boolean |
| 593 |
*/ |
| 594 |
public static function permission_read_campaign( $input = null ) { |
| 595 |
/* |
| 596 |
* THE FLOOR, and it must come first. |
| 597 |
* |
| 598 |
* The object-level check below NARROWS access; it must never widen it. |
| 599 |
* Without this line the callback REPLACED the general capability, and |
| 600 |
* for a PUBLISHED campaign `read_campaign` resolves — via core's |
| 601 |
* map_meta_cap() `read_post` branch, which short-circuits on any public |
| 602 |
* post status — to `$post_type->cap->read`. The `campaign` post type |
| 603 |
* registers `capability_type => 'campaign'` with no explicit |
| 604 |
* `capabilities` array, so get_post_type_capabilities() leaves that as |
| 605 |
* the plain core `read`, which every logged-in role holds. Four read |
| 606 |
* abilities were therefore open to any Subscriber: verified over |
| 607 |
* wp-abilities/v1, HTTP 200 carrying donor_count and average_gift. |
| 608 |
* |
| 609 |
* Caught by |
| 610 |
* test_a_user_with_no_charitable_capability_cannot_read_a_published_campaign(). |
| 611 |
*/ |
| 612 |
if ( ! current_user_can( 'edit_campaigns' ) ) { |
| 613 |
return false; |
| 614 |
} |
| 615 |
|
| 616 |
$campaign_id = is_array( $input ) ? self::input_id( $input, 'campaign_id' ) : 0; |
| 617 |
|
| 618 |
if ( $campaign_id < 1 ) { |
| 619 |
return true; |
| 620 |
} |
| 621 |
|
| 622 |
$post = get_post( $campaign_id ); |
| 623 |
|
| 624 |
/* |
| 625 |
* Not a campaign: defer so the ability's own 404 "no campaign was |
| 626 |
* found with that ID" is what the caller sees. |
| 627 |
* |
| 628 |
* This is load-bearing, not tidiness. current_user_can( 'read_campaign', |
| 629 |
* <id that does not exist> ) is false, which would turn every mistyped |
| 630 |
* id into a 403 — and the REST-channel suite asserts a missing record |
| 631 |
* is a 404 precisely because a permission error tells an assistant the |
| 632 |
* wrong thing: 404 makes it ask which campaign you meant, 403 makes it |
| 633 |
* report an access problem that does not exist. Caught by |
| 634 |
* test_a_missing_record_is_a_404_not_a_500(). |
| 635 |
*/ |
| 636 |
if ( ! $post || 'campaign' !== $post->post_type ) { |
| 637 |
return true; |
| 638 |
} |
| 639 |
|
| 640 |
/* The caller's own campaign, whatever its status. */ |
| 641 |
if ( (int) $post->post_author === get_current_user_id() ) { |
| 642 |
return true; |
| 643 |
} |
| 644 |
|
| 645 |
/* |
| 646 |
* Somebody else's campaign. `read_campaign` is the meta capability, so |
| 647 |
* core resolves it against the post: a published campaign stays |
| 648 |
* readable — its goal and amount raised are already public on its |
| 649 |
* own page, and get-campaign-performance additionally exposes |
| 650 |
* donor_count and average_gift to any edit_campaigns holder this |
| 651 |
* way, an accepted boundary (U4 in the audit findings doc) rather |
| 652 |
* than an oversight — while a draft or private one requires |
| 653 |
* read_private_campaigns or edit_others_campaigns, which that role |
| 654 |
* does not hold. |
| 655 |
*/ |
| 656 |
return current_user_can( 'read_campaign', $campaign_id ); |
| 657 |
} |
| 658 |
|
| 659 |
/** |
| 660 |
* Permission callback: writing to ONE campaign. |
| 661 |
* |
| 662 |
* `edit_campaign` is the meta capability, which core resolves to |
| 663 |
* `edit_others_campaigns` when the caller is not the post's author. That |
| 664 |
* is precisely the check that stops a `campaign_creator` opening someone |
| 665 |
* else's campaign in wp-admin, and gating on the plural `edit_campaigns` |
| 666 |
* bypassed it. |
| 667 |
* |
| 668 |
* @since 1.8.13 |
| 669 |
* |
| 670 |
* @param array|null $input Ability input. |
| 671 |
* @return boolean |
| 672 |
*/ |
| 673 |
public static function permission_edit_campaign( $input = null ) { |
| 674 |
$campaign_id = is_array( $input ) ? self::input_id( $input, 'campaign_id' ) : 0; |
| 675 |
|
| 676 |
if ( $campaign_id < 1 ) { |
| 677 |
return current_user_can( 'edit_campaigns' ); |
| 678 |
} |
| 679 |
|
| 680 |
$post = get_post( $campaign_id ); |
| 681 |
|
| 682 |
/* |
| 683 |
* Not a campaign: defer to the general capability so the ability's own |
| 684 |
* "no campaign was found with that ID" error is what the caller sees, |
| 685 |
* rather than a permission error that would confirm nothing either way. |
| 686 |
*/ |
| 687 |
if ( ! $post || 'campaign' !== $post->post_type ) { |
| 688 |
return current_user_can( 'edit_campaigns' ); |
| 689 |
} |
| 690 |
|
| 691 |
/* |
| 692 |
* Somebody else's campaign: `edit_others_campaigns`, which is what |
| 693 |
* core's map_meta_cap resolves `edit_campaign` to for a non-author, |
| 694 |
* and therefore what wp-admin enforces. |
| 695 |
*/ |
| 696 |
if ( (int) $post->post_author !== get_current_user_id() ) { |
| 697 |
return current_user_can( 'edit_others_campaigns' ); |
| 698 |
} |
| 699 |
|
| 700 |
/* |
| 701 |
* The caller's OWN campaign: the general capability is enough, and |
| 702 |
* `edit_campaign` deliberately is NOT used here. |
| 703 |
* |
| 704 |
* That looks like the stricter, more obvious choice and it is wrong. |
| 705 |
* For a PUBLISHED post core resolves `edit_campaign` to |
| 706 |
* `edit_published_campaigns`, which Ambassadors' `campaign_creator` |
| 707 |
* role does not hold — so using the meta capability would stop an |
| 708 |
* ambassador editing their own live fundraiser through an ability, |
| 709 |
* which is the entire purpose of that role. Caught by |
| 710 |
* test_a_campaign_creator_can_still_write_to_its_own_campaign(). |
| 711 |
* |
| 712 |
* Authorship is the axis that matters for the vulnerability this |
| 713 |
* guards; the published/draft split is not. Same shape as |
| 714 |
* permission_create_manual_donation(), which likewise falls back to an |
| 715 |
* ownership test rather than a broader capability. |
| 716 |
*/ |
| 717 |
return current_user_can( 'edit_campaigns' ); |
| 718 |
} |
| 719 |
|
| 720 |
/** |
| 721 |
* Permission callback: publishing or re-statusing ONE campaign. |
| 722 |
* |
| 723 |
* Charitable's own role model (Charitable_Roles::get_core_caps(), |
| 724 |
* includes/users/class-charitable-roles.php) defines `publish_campaigns` |
| 725 |
* as a capability DISTINCT from `edit_campaigns` — mirroring core |
| 726 |
* WordPress's own `edit_posts`/`publish_posts` split. Both stock |
| 727 |
* Charitable roles (`administrator`, `campaign_manager`) happen to be |
| 728 |
* granted both capabilities as a bundle, which is exactly why gating a |
| 729 |
* status-changing write on `permission_campaigns()` alone can look safe |
| 730 |
* in testing and still be wrong: `wp_insert_post()`/`wp_update_post()` |
| 731 |
* perform NO internal capability check of their own, so a role-editor |
| 732 |
* plugin that splits "draft" from "publish" — a common nonprofit setup |
| 733 |
* ("volunteers draft, staff approve and publish") — would let a |
| 734 |
* draft-only user publish through an ability even though the equivalent |
| 735 |
* admin-UI Publish button would be hidden from them. |
| 736 |
* |
| 737 |
* INPUT-AWARE as of the Audit 1 remediation. The capability alone is not |
| 738 |
* enough: it carries no post id and no author check, so a role holding |
| 739 |
* publish_campaigns without edit_others_campaigns could take ANOTHER |
| 740 |
* author's live fundraiser offline through this ability — access wp-admin |
| 741 |
* refuses, because core's map_meta_cap() resolves `edit_post` to |
| 742 |
* `edit_others_campaigns` for a non-author. Confirmed by execution before |
| 743 |
* the fix. Same axis as permission_edit_campaign(): authorship, not |
| 744 |
* published-vs-draft. |
| 745 |
* |
| 746 |
* @since 1.8.13 |
| 747 |
* |
| 748 |
* @param array|null $input Ability input. |
| 749 |
* @return boolean |
| 750 |
*/ |
| 751 |
public static function permission_publish_campaigns( $input = null ) { |
| 752 |
if ( ! current_user_can( 'publish_campaigns' ) ) { |
| 753 |
return false; |
| 754 |
} |
| 755 |
|
| 756 |
$campaign_id = is_array( $input ) ? self::input_id( $input, 'campaign_id' ) : 0; |
| 757 |
|
| 758 |
if ( $campaign_id < 1 ) { |
| 759 |
return true; |
| 760 |
} |
| 761 |
|
| 762 |
$post = get_post( $campaign_id ); |
| 763 |
|
| 764 |
/* |
| 765 |
* Not a campaign: defer, so the ability's own 404 answers rather than a |
| 766 |
* permission error that would confirm nothing either way. Same reasoning |
| 767 |
* as permission_read_campaign() and permission_edit_campaign(). |
| 768 |
*/ |
| 769 |
if ( ! $post || 'campaign' !== $post->post_type ) { |
| 770 |
return true; |
| 771 |
} |
| 772 |
|
| 773 |
if ( (int) $post->post_author !== get_current_user_id() ) { |
| 774 |
return current_user_can( 'edit_others_campaigns' ); |
| 775 |
} |
| 776 |
|
| 777 |
return true; |
| 778 |
} |
| 779 |
|
| 780 |
/** |
| 781 |
* Permission callback: donation read and manual creation. |
| 782 |
* |
| 783 |
* The plural, GENERAL capability: it carries no donation id and no author |
| 784 |
* check. It is the FLOOR for every donation ability, and it is enough on |
| 785 |
* its own only where there is no single donation to check — a listing |
| 786 |
* whose own query is author-scoped (execute_list_donations()), or a |
| 787 |
* summary whose own query is (execute_get_donation_summary()). |
| 788 |
* |
| 789 |
* For anything taking a `donation_id`, use permission_read_donation() |
| 790 |
* instead, which additionally asks whose donation it is. |
| 791 |
* |
| 792 |
* @since 1.8.13 |
| 793 |
* |
| 794 |
* @return boolean |
| 795 |
*/ |
| 796 |
public static function permission_donations() { |
| 797 |
return current_user_can( 'edit_donations' ); |
| 798 |
} |
| 799 |
|
| 800 |
/** |
| 801 |
* Permission callback: reading ONE donation. |
| 802 |
* |
| 803 |
* `edit_donations` is the floor and must come first, so a caller holding |
| 804 |
* no Charitable capability at all is refused whoever authored the |
| 805 |
* donation. Above that floor this asks the question the general |
| 806 |
* capability cannot: whose donation is it? |
| 807 |
* |
| 808 |
* The rule is Charitable's own, taken from the wp-admin donation list |
| 809 |
* table, which filters its query by author for anyone without |
| 810 |
* `edit_others_donations` |
| 811 |
* (includes/admin/donations/class-charitable-donation-list-table.php:783). |
| 812 |
* A donation's `post_author` is the donor's WordPress user id — |
| 813 |
* Charitable_Donation_Processor::parse_donation_data() sets |
| 814 |
* `post_author => $this->get_donation_data_value( 'user_id', |
| 815 |
* get_current_user_id() )` (line 1022) — so author IS the ownership axis |
| 816 |
* here, exactly as it is for campaigns. A guest or manually recorded |
| 817 |
* donation carries post_author 0 and is therefore NOT the caller's own, |
| 818 |
* which is the same answer the list table gives: its `author` filter is |
| 819 |
* the current user id, and 0 never matches it. |
| 820 |
* |
| 821 |
* The author comparison is written out rather than delegated to |
| 822 |
* `current_user_can( 'read_donation', $id )`, and that is a choice, not an |
| 823 |
* oversight. The meta capability would in fact work — `donation` |
| 824 |
* registers `map_meta_cap => true` |
| 825 |
* (Charitable_Post_Types::register_post_types():322), and since every |
| 826 |
* `charitable-*` status is registered `public => false` with no `private` |
| 827 |
* flag (register_post_statuses(), same file), core's `read_post` branch |
| 828 |
* falls through to map_meta_cap( 'edit_post' ) and lands on |
| 829 |
* `edit_others_donations` for a non-author. It is not used because that |
| 830 |
* result is four hops of core derivation through non-public custom |
| 831 |
* statuses: change any one of those `public` flags and this gate silently |
| 832 |
* becomes a different check. Writing the rule out keeps it identical to |
| 833 |
* the list table's, and identical to the two query-level sites |
| 834 |
* (execute_list_donations(), execute_get_donation_summary()) which have no |
| 835 |
* meta capability available to them at all. |
| 836 |
* |
| 837 |
* A missing id, or an id belonging to some other post type, DEFERS to the |
| 838 |
* floor rather than refusing. That is load-bearing: the ability's own |
| 839 |
* "no donation with that id exists" is a 404, and the REST-channel suite |
| 840 |
* asserts it, because a 403 tells an assistant it has an access problem |
| 841 |
* when what it really has is a wrong id. Same reasoning as |
| 842 |
* permission_read_campaign(). It must also tolerate `array()` or `null` |
| 843 |
* without a notice — the contract suite calls check_permissions( array() ) |
| 844 |
* on every registered ability. |
| 845 |
* |
| 846 |
* DIVERGENCE FROM PRO, deliberate and documented. Pro 1.8.18 (the pinned |
| 847 |
* source these abilities were ported from) gates get-donation on |
| 848 |
* permission_donations() alone and has the same defect. Pro should adopt |
| 849 |
* this change so the shared schema does not drift silently. |
| 850 |
* |
| 851 |
* @since 1.8.13 |
| 852 |
* |
| 853 |
* @param array|null $input Ability input. |
| 854 |
* @return boolean |
| 855 |
*/ |
| 856 |
public static function permission_read_donation( $input = null ) { |
| 857 |
if ( ! current_user_can( 'edit_donations' ) ) { |
| 858 |
return false; |
| 859 |
} |
| 860 |
|
| 861 |
$donation_id = is_array( $input ) ? self::input_id( $input, 'donation_id' ) : 0; |
| 862 |
|
| 863 |
if ( $donation_id < 1 ) { |
| 864 |
return true; |
| 865 |
} |
| 866 |
|
| 867 |
$post = get_post( $donation_id ); |
| 868 |
|
| 869 |
/* Not a donation: defer, so the ability's own 404 is what the caller sees. */ |
| 870 |
if ( ! $post || 'donation' !== $post->post_type ) { |
| 871 |
return true; |
| 872 |
} |
| 873 |
|
| 874 |
/* The caller's own donation. */ |
| 875 |
if ( (int) $post->post_author === get_current_user_id() ) { |
| 876 |
return true; |
| 877 |
} |
| 878 |
|
| 879 |
return current_user_can( 'edit_others_donations' ); |
| 880 |
} |
| 881 |
|
| 882 |
/** |
| 883 |
* Permission callback: donor personally identifiable information. |
| 884 |
* |
| 885 |
* @since 1.8.13 |
| 886 |
* |
| 887 |
* @return boolean |
| 888 |
*/ |
| 889 |
public static function permission_sensitive() { |
| 890 |
return current_user_can( 'view_charitable_sensitive_data' ); |
| 891 |
} |
| 892 |
|
| 893 |
/** |
| 894 |
* Permission callback: reporting. |
| 895 |
* |
| 896 |
* @since 1.8.13 |
| 897 |
* |
| 898 |
* @return boolean |
| 899 |
*/ |
| 900 |
public static function permission_reports() { |
| 901 |
return current_user_can( 'export_charitable_reports' ); |
| 902 |
} |
| 903 |
|
| 904 |
/** |
| 905 |
* Permission callback: a REPORT that also names donors. |
| 906 |
* |
| 907 |
* BOTH capabilities, not either. `export_charitable_reports` is what every |
| 908 |
* other report ability requires — get-reports-overview, |
| 909 |
* get-reports-activity, get-top-campaigns and get-reports-date-ranges all |
| 910 |
* gate on permission_reports() — and `view_charitable_sensitive_data` is |
| 911 |
* the PII gate on top, because the output carries donor names and email |
| 912 |
* addresses. |
| 913 |
* |
| 914 |
* get-top-donors required the sensitive capability ALONE, so a role |
| 915 |
* holding it without the reports capability was served a ranked list of |
| 916 |
* donor names, emails and lifetime totals from an ability its four |
| 917 |
* siblings would have refused. The sensitive capability is the narrower |
| 918 |
* of the two and reads like the stricter choice, which is exactly why |
| 919 |
* this was easy to miss: it is narrower on a different axis. It says |
| 920 |
* "may see donor PII", not "may run reports". |
| 921 |
* |
| 922 |
* DIVERGENCE FROM PRO, deliberate and documented. Pro 1.8.18 (the pinned |
| 923 |
* source these abilities were ported from) has the same defect. Pro |
| 924 |
* should adopt this change so the shared schema does not drift silently. |
| 925 |
* |
| 926 |
* @since 1.8.13 |
| 927 |
* |
| 928 |
* @return boolean |
| 929 |
*/ |
| 930 |
public static function permission_sensitive_reports() { |
| 931 |
return current_user_can( 'export_charitable_reports' ) |
| 932 |
&& current_user_can( 'view_charitable_sensitive_data' ); |
| 933 |
} |
| 934 |
|
| 935 |
/** |
| 936 |
* Return a monetary amount in both raw and formatted form. |
| 937 |
* |
| 938 |
* Abilities always return both so a client never has to parse a currency |
| 939 |
* symbol or a thousands separator. The decimal count is resolved via |
| 940 |
* get_decimals() and passed to get_monetary_amount() explicitly — its |
| 941 |
* own `false` default falls back to |
| 942 |
* `charitable_get_option( 'decimal_count', 2 )`, and |
| 943 |
* charitable_get_option() returns that hardcoded 2 without ever calling |
| 944 |
* apply_filters() when the `decimal_count` key is absent from the saved |
| 945 |
* settings (true for any site whose admin has never saved General |
| 946 |
* Settings). That silently skips the `charitable_option_decimal_count` |
| 947 |
* filter Charitable_Currency::maybe_force_zero_decimals() hooks into, so |
| 948 |
* a zero-decimal currency like JPY would render with 2 decimals. |
| 949 |
* get_decimals() does not have this gap: it computes a currency-aware |
| 950 |
* default (0 for zero-decimal currencies) before calling the option |
| 951 |
* getter, so it is correct even when nothing has been saved. Do not |
| 952 |
* revert the explicit `$helper->get_decimals()` argument below back to |
| 953 |
* `false` — the filter does not cover this case. |
| 954 |
* |
| 955 |
* @since 1.8.13 |
| 956 |
* |
| 957 |
* @param mixed $amount The amount. May be a locale-formatted string. Non-scalar |
| 958 |
* input (e.g. an array) is treated as 0 rather than coerced. |
| 959 |
* @return array { |
| 960 |
* @type float $raw Numeric amount. |
| 961 |
* @type string $formatted Localised, currency-symbolised amount. |
| 962 |
* } |
| 963 |
*/ |
| 964 |
protected static function money( $amount ) { |
| 965 |
if ( ! is_scalar( $amount ) ) { |
| 966 |
$amount = 0; |
| 967 |
} |
| 968 |
|
| 969 |
$helper = charitable_get_currency_helper(); |
| 970 |
$raw = $helper->sanitize_monetary_amount( (string) $amount, true ); |
| 971 |
$formatted = $helper->get_monetary_amount( $raw, $helper->get_decimals(), true ); |
| 972 |
|
| 973 |
/* |
| 974 |
* get_currency_symbol() (class-charitable-currency.php) returns HTML |
| 975 |
* entities by design — '$' for USD/AUD/CAD/etc, '¥' for |
| 976 |
* JPY/CNY — which is correct wherever Charitable renders this into |
| 977 |
* HTML. get_monetary_amount() above embeds that symbol, so |
| 978 |
* $formatted inherits the entities (reproduced live: goal_formatted |
| 979 |
* came back as '$12,500.00'). This is a JSON surface read by AI |
| 980 |
* clients, not HTML, so the entities must be decoded here rather |
| 981 |
* than "fixed" upstream in the currency class — an assistant reading |
| 982 |
* the raw entity aloud says "and pound 36, 12,500.00" instead of |
| 983 |
* "$12,500.00". Do not remove this decode. |
| 984 |
*/ |
| 985 |
$formatted = html_entity_decode( $formatted, ENT_QUOTES, 'UTF-8' ); |
| 986 |
|
| 987 |
return array( |
| 988 |
'raw' => (float) $raw, |
| 989 |
'formatted' => $formatted, |
| 990 |
); |
| 991 |
} |
| 992 |
|
| 993 |
/** |
| 994 |
* Parse an inbound monetary amount, rejecting excess precision. |
| 995 |
* |
| 996 |
* On a zero-decimal currency site an amount carrying decimal places is |
| 997 |
* refused rather than silently floored — quietly altering money is worse |
| 998 |
* than failing the call. |
| 999 |
* |
| 1000 |
* Non-scalar input (e.g. an array) is rejected with a WP_Error rather |
| 1001 |
* than coerced, which would otherwise emit an "Array to string |
| 1002 |
* conversion" notice. |
| 1003 |
* |
| 1004 |
* @since 1.8.13 |
| 1005 |
* |
| 1006 |
* @param mixed $amount Inbound amount. JSON may deliver int, float or string. |
| 1007 |
* @return float|WP_Error |
| 1008 |
*/ |
| 1009 |
protected static function parse_money( $amount ) { |
| 1010 |
if ( ! is_scalar( $amount ) ) { |
| 1011 |
return new WP_Error( |
| 1012 |
'charitable_invalid_amount', |
| 1013 |
__( 'The amount must be a number or numeric string.', 'charitable' ) |
| 1014 |
); |
| 1015 |
} |
| 1016 |
|
| 1017 |
$helper = charitable_get_currency_helper(); |
| 1018 |
$decimals = $helper->get_decimals(); |
| 1019 |
|
| 1020 |
/* sanitize_monetary_amount() requires a string; a float triggers doing_it_wrong(). */ |
| 1021 |
$sanitized = $helper->sanitize_monetary_amount( (string) $amount, true ); |
| 1022 |
|
| 1023 |
if ( 0 === (int) $decimals && (float) $sanitized !== floor( (float) $sanitized ) ) { |
| 1024 |
return new WP_Error( |
| 1025 |
'charitable_invalid_amount', |
| 1026 |
sprintf( |
| 1027 |
/* translators: %s: currency code. */ |
| 1028 |
__( 'The site currency (%s) does not use decimal places. Supply a whole number amount.', 'charitable' ), |
| 1029 |
charitable_get_currency() |
| 1030 |
) |
| 1031 |
); |
| 1032 |
} |
| 1033 |
|
| 1034 |
return (float) $sanitized; |
| 1035 |
} |
| 1036 |
|
| 1037 |
/** |
| 1038 |
* Strip the data payload from a WP_Error before it leaves an ability. |
| 1039 |
* |
| 1040 |
* Error data can carry licence keys, admin email and gateway envelopes. |
| 1041 |
* An MCP bridge that serialises get_error_data() would log all of it, so |
| 1042 |
* the data payload is dropped at the boundary. The message is preserved. |
| 1043 |
* |
| 1044 |
* @since 1.8.13 |
| 1045 |
* |
| 1046 |
* @param mixed $error Possibly a WP_Error. |
| 1047 |
* @return mixed Scrubbed WP_Error, or the original value untouched. |
| 1048 |
*/ |
| 1049 |
protected static function scrub_error( $error ) { |
| 1050 |
if ( ! is_wp_error( $error ) ) { |
| 1051 |
return $error; |
| 1052 |
} |
| 1053 |
|
| 1054 |
return new WP_Error( $error->get_error_code(), $error->get_error_message() ); |
| 1055 |
} |
| 1056 |
|
| 1057 |
/** |
| 1058 |
* Log a successful write performed by an ability. |
| 1059 |
* |
| 1060 |
* Reads are deliberately not logged — 26 abilities inside an agentic loop |
| 1061 |
* would flood the log table. Opt in with the |
| 1062 |
* `charitable_abilities_log_reads` filter. |
| 1063 |
* |
| 1064 |
* @since 1.8.13 |
| 1065 |
* |
| 1066 |
* @param string $ability The ability slug. |
| 1067 |
* @param string $message Human-readable message. |
| 1068 |
* @param array $context Optional. Correlation ids: campaign_id, donation_id, donor_id. |
| 1069 |
* @return void |
| 1070 |
*/ |
| 1071 |
protected static function log_write( $ability, $message, array $context = array() ) { |
| 1072 |
charitable_log( |
| 1073 |
$ability, |
| 1074 |
$message, |
| 1075 |
array_merge( |
| 1076 |
array( |
| 1077 |
'type' => 'abilities', |
| 1078 |
'level' => 'info', |
| 1079 |
'source' => 'core', |
| 1080 |
'object_type' => 'ability', |
| 1081 |
'user_id' => get_current_user_id(), |
| 1082 |
), |
| 1083 |
$context |
| 1084 |
) |
| 1085 |
); |
| 1086 |
} |
| 1087 |
|
| 1088 |
/** |
| 1089 |
* Log a read, but only if a site has opted in. |
| 1090 |
* |
| 1091 |
* Reads are NOT logged by default and this is a deliberate default, not |
| 1092 |
* an omission: an agentic loop calling a couple of dozen read abilities |
| 1093 |
* per turn would fill the log table with rows nobody will read, and |
| 1094 |
* Charitable has already had one debug line put 3,033 entries into a |
| 1095 |
* customer's log in a day. |
| 1096 |
* |
| 1097 |
* A site that is debugging what an assistant is actually doing opts in: |
| 1098 |
* |
| 1099 |
* add_filter( 'charitable_abilities_log_reads', '__return_true' ); |
| 1100 |
* |
| 1101 |
* Level is `debug`, so opting in does not mix read noise in with the |
| 1102 |
* writes and failures at `info` and above. |
| 1103 |
* |
| 1104 |
* @since 1.8.13 |
| 1105 |
* |
| 1106 |
* @param string $ability The ability slug. |
| 1107 |
* @param array $context Optional. Correlation ids. |
| 1108 |
* @return void |
| 1109 |
*/ |
| 1110 |
public static function log_read( $ability, array $context = array() ) { |
| 1111 |
/** |
| 1112 |
* Filter whether read abilities write a log entry. |
| 1113 |
* |
| 1114 |
* Off by default. Turn it on to trace what an AI client is reading; |
| 1115 |
* expect a row per ability call. |
| 1116 |
* |
| 1117 |
* @since 1.8.13 |
| 1118 |
* |
| 1119 |
* @param boolean $enabled Whether to log reads. Default false. |
| 1120 |
* @param string $ability The ability slug about to be logged. |
| 1121 |
*/ |
| 1122 |
if ( ! apply_filters( 'charitable_abilities_log_reads', false, $ability ) ) { |
| 1123 |
return; |
| 1124 |
} |
| 1125 |
|
| 1126 |
charitable_log( |
| 1127 |
$ability, |
| 1128 |
__( 'Read.', 'charitable' ), |
| 1129 |
array_merge( |
| 1130 |
array( |
| 1131 |
'type' => 'abilities', |
| 1132 |
'level' => 'debug', |
| 1133 |
'source' => 'core', |
| 1134 |
'object_type' => 'ability', |
| 1135 |
'user_id' => get_current_user_id(), |
| 1136 |
), |
| 1137 |
$context |
| 1138 |
) |
| 1139 |
); |
| 1140 |
} |
| 1141 |
|
| 1142 |
/** |
| 1143 |
* Log a write refused by the AI write gate. |
| 1144 |
* |
| 1145 |
* Public because the refusal is recorded from inside the gate closure |
| 1146 |
* rather than from a subclass, and because the AI Activity panel's tests |
| 1147 |
* drive it directly. |
| 1148 |
* |
| 1149 |
* `source` is `write-gate`, not the `core` default every other log_*() |
| 1150 |
* method here uses: it is the machine-readable discriminator |
| 1151 |
* `Charitable_Tools_AI_MCP::outcome_from_record()` switches on to tell |
| 1152 |
* this refusal apart from a permission denial, since the two share a |
| 1153 |
* log level and their message text is not safe to match on a |
| 1154 |
* translated site. Registered for Tools > Logs by |
| 1155 |
* `Charitable_Abilities::register_log_sources()`. Pro 1.8.18 still logs |
| 1156 |
* this call with `source => 'core'`; adopt `write-gate` there when Pro |
| 1157 |
* gains an equivalent panel. |
| 1158 |
* |
| 1159 |
* The MESSAGE is a FIXED string. It named which of three scopes was off |
| 1160 |
* while 1.8.13 had scoped permissions; with one switch there is only one |
| 1161 |
* thing it could name, and the sentence already says it. |
| 1162 |
* |
| 1163 |
* Fixed is also what the panel relies on: outcome_from_record() |
| 1164 |
* classifies a refusal on `source => 'write-gate'` and never on this |
| 1165 |
* text, which is what lets the row survive translation. |
| 1166 |
* `Test_Charitable_Abilities_Activity::test_refused_and_denied_survive_translation` |
| 1167 |
* keeps that true. |
| 1168 |
* |
| 1169 |
* @since 1.8.13 |
| 1170 |
* |
| 1171 |
* @param string $ability The ability slug. |
| 1172 |
* @return void |
| 1173 |
*/ |
| 1174 |
public static function log_write_refused( $ability ) { |
| 1175 |
|
| 1176 |
charitable_log( |
| 1177 |
$ability, |
| 1178 |
__( 'Refused: AI write access is turned off for this site.', 'charitable' ), |
| 1179 |
array( |
| 1180 |
'type' => 'abilities', |
| 1181 |
'level' => 'warning', |
| 1182 |
'source' => 'write-gate', |
| 1183 |
'object_type' => 'ability', |
| 1184 |
'user_id' => get_current_user_id(), |
| 1185 |
) |
| 1186 |
); |
| 1187 |
} |
| 1188 |
|
| 1189 |
/** |
| 1190 |
* Log a permission denial. |
| 1191 |
* |
| 1192 |
* `source` is `permission`, not the `core` default every other |
| 1193 |
* log_*() method here uses: it is the machine-readable discriminator |
| 1194 |
* `Charitable_Tools_AI_MCP::outcome_from_record()` switches on to |
| 1195 |
* tell this denial apart from a write-gate refusal, since the two |
| 1196 |
* share a log level and their message text is not safe to match on a |
| 1197 |
* translated site. Registered for Tools > Logs by |
| 1198 |
* `Charitable_Abilities::register_log_sources()`. Pro 1.8.18 still logs |
| 1199 |
* this call with `source => 'core'`; adopt `permission` there when Pro |
| 1200 |
* gains an equivalent panel. |
| 1201 |
* |
| 1202 |
* @since 1.8.13 |
| 1203 |
* |
| 1204 |
* @param string $ability The ability slug. |
| 1205 |
* @return void |
| 1206 |
*/ |
| 1207 |
public static function log_denial( $ability ) { |
| 1208 |
charitable_log( |
| 1209 |
$ability, |
| 1210 |
__( 'Permission denied.', 'charitable' ), |
| 1211 |
array( |
| 1212 |
'type' => 'abilities', |
| 1213 |
'level' => 'warning', |
| 1214 |
'source' => 'permission', |
| 1215 |
'object_type' => 'ability', |
| 1216 |
'user_id' => get_current_user_id(), |
| 1217 |
) |
| 1218 |
); |
| 1219 |
} |
| 1220 |
|
| 1221 |
/** |
| 1222 |
* Log a validation failure or returned WP_Error. |
| 1223 |
* |
| 1224 |
* @since 1.8.13 |
| 1225 |
* |
| 1226 |
* @param string $ability The ability slug. |
| 1227 |
* @param mixed $error WP_Error or message string. |
| 1228 |
* @return void |
| 1229 |
*/ |
| 1230 |
protected static function log_failure( $ability, $error ) { |
| 1231 |
$message = is_wp_error( $error ) ? $error->get_error_message() : (string) $error; |
| 1232 |
|
| 1233 |
charitable_log( |
| 1234 |
$ability, |
| 1235 |
$message, |
| 1236 |
array( |
| 1237 |
'type' => 'abilities', |
| 1238 |
'level' => 'error', |
| 1239 |
'source' => 'core', |
| 1240 |
'object_type' => 'ability', |
| 1241 |
'user_id' => get_current_user_id(), |
| 1242 |
) |
| 1243 |
); |
| 1244 |
} |
| 1245 |
|
| 1246 |
/* |
| 1247 |
* --------------------------------------------------------------------- |
| 1248 |
* Shared date handling. |
| 1249 |
* |
| 1250 |
* Lives on the base class because every ability taking a date range needs |
| 1251 |
* the same three things and must not get any of them wrong twice: a real |
| 1252 |
* calendar-date check (strtotime() accepts "2026-02-31"), an inverted-range |
| 1253 |
* refusal, and an INCLUSIVE date_query so an end date of today does not |
| 1254 |
* drop today. |
| 1255 |
* --------------------------------------------------------------------- |
| 1256 |
*/ |
| 1257 |
|
| 1258 |
/** |
| 1259 |
* Validate a YYYY-MM-DD string, rejecting impossible calendar dates. |
| 1260 |
* |
| 1261 |
* Note that strtotime() accepts "2026-02-31" and rolls it forward to March, so a |
| 1262 |
* checkdate() pass is required rather than a parse alone. |
| 1263 |
* |
| 1264 |
* @since 1.8.13 |
| 1265 |
* |
| 1266 |
* @param string $value The candidate date. |
| 1267 |
* @return string|WP_Error |
| 1268 |
*/ |
| 1269 |
protected static function validate_ymd( $value ) { |
| 1270 |
/* Non-scalar in, clean rejection out — never an "Array to string" notice. */ |
| 1271 |
if ( ! is_scalar( $value ) ) { |
| 1272 |
return new WP_Error( |
| 1273 |
'charitable_invalid_date', |
| 1274 |
__( 'Dates must be supplied as YYYY-MM-DD.', 'charitable' ) |
| 1275 |
); |
| 1276 |
} |
| 1277 |
|
| 1278 |
$value = trim( (string) $value ); |
| 1279 |
|
| 1280 |
if ( ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches ) ) { |
| 1281 |
return new WP_Error( |
| 1282 |
'charitable_invalid_date', |
| 1283 |
__( 'Dates must be supplied as YYYY-MM-DD.', 'charitable' ) |
| 1284 |
); |
| 1285 |
} |
| 1286 |
|
| 1287 |
if ( ! checkdate( (int) $matches[2], (int) $matches[3], (int) $matches[1] ) ) { |
| 1288 |
return new WP_Error( |
| 1289 |
'charitable_invalid_date', |
| 1290 |
__( 'That is not a real calendar date.', 'charitable' ) |
| 1291 |
); |
| 1292 |
} |
| 1293 |
|
| 1294 |
return $value; |
| 1295 |
} |
| 1296 |
|
| 1297 |
/** |
| 1298 |
* Parse an optional start_date/end_date pair out of ability input. |
| 1299 |
* |
| 1300 |
* @since 1.8.13 |
| 1301 |
* |
| 1302 |
* @param array $input Ability input. |
| 1303 |
* @param string $default_start Fallback start date. |
| 1304 |
* @param string $default_end Fallback end date. |
| 1305 |
* @return array|WP_Error |
| 1306 |
*/ |
| 1307 |
protected static function parse_date_range( $input, $default_start, $default_end ) { |
| 1308 |
/* |
| 1309 |
* Read through input_string(), not isset() plus a cast. isset() is TRUE |
| 1310 |
* for an array or object value, so `trim( (string) $input['start_date'] )` |
| 1311 |
* raised "Array to string conversion" on an array — found by fuzzing the |
| 1312 |
* execute_* methods directly, which is a reachable path because they must |
| 1313 |
* be public. A non-scalar date now falls through to the default exactly |
| 1314 |
* as an absent one does. |
| 1315 |
*/ |
| 1316 |
$supplied_start = self::input_string( $input, 'start_date' ); |
| 1317 |
|
| 1318 |
$start = '' !== trim( $supplied_start ) |
| 1319 |
? self::validate_ymd( $supplied_start ) |
| 1320 |
: $default_start; |
| 1321 |
|
| 1322 |
if ( is_wp_error( $start ) ) { |
| 1323 |
return $start; |
| 1324 |
} |
| 1325 |
|
| 1326 |
$supplied_end = self::input_string( $input, 'end_date' ); |
| 1327 |
|
| 1328 |
$end = '' !== trim( $supplied_end ) |
| 1329 |
? self::validate_ymd( $supplied_end ) |
| 1330 |
: $default_end; |
| 1331 |
|
| 1332 |
if ( is_wp_error( $end ) ) { |
| 1333 |
return $end; |
| 1334 |
} |
| 1335 |
|
| 1336 |
if ( '' !== $start && '' !== $end && $start > $end ) { |
| 1337 |
return new WP_Error( |
| 1338 |
'charitable_invalid_date_range', |
| 1339 |
__( 'The start date must not be later than the end date.', 'charitable' ) |
| 1340 |
); |
| 1341 |
} |
| 1342 |
|
| 1343 |
return array( |
| 1344 |
'start_date' => $start, |
| 1345 |
'end_date' => $end, |
| 1346 |
); |
| 1347 |
} |
| 1348 |
|
| 1349 |
/** |
| 1350 |
* Build a WP_Query date_query clause from a parsed range. |
| 1351 |
* |
| 1352 |
* `inclusive => true` matters: without it an end_date of 2026-09-04 |
| 1353 |
* excludes everything donated on 2026-09-04, so "up to today" silently |
| 1354 |
* drops today's donations. |
| 1355 |
* |
| 1356 |
* @since 1.8.13 |
| 1357 |
* |
| 1358 |
* @param array $dates Parsed start_date/end_date. |
| 1359 |
* @return array |
| 1360 |
*/ |
| 1361 |
protected static function date_query_clause( array $dates ) { |
| 1362 |
$clause = array( 'inclusive' => true ); |
| 1363 |
|
| 1364 |
if ( '' !== $dates['start_date'] ) { |
| 1365 |
$clause['after'] = $dates['start_date']; |
| 1366 |
} |
| 1367 |
|
| 1368 |
if ( '' !== $dates['end_date'] ) { |
| 1369 |
$clause['before'] = $dates['end_date']; |
| 1370 |
} |
| 1371 |
|
| 1372 |
return $clause; |
| 1373 |
} |
| 1374 |
|
| 1375 |
/** |
| 1376 |
* A reusable empty-object input schema. |
| 1377 |
* |
| 1378 |
* @since 1.8.13 |
| 1379 |
* |
| 1380 |
* @return array |
| 1381 |
*/ |
| 1382 |
protected static function no_input_schema() { |
| 1383 |
return array( |
| 1384 |
/* |
| 1385 |
* Plain `object` here. register_ability() widens it to |
| 1386 |
* [object, null] through allow_absent_input(), which does the |
| 1387 |
* same for every schema that requires nothing - see that method |
| 1388 |
* for why a bare call has to be valid. |
| 1389 |
*/ |
| 1390 |
'type' => 'object', |
| 1391 |
'properties' => array(), |
| 1392 |
'additionalProperties' => false, |
| 1393 |
); |
| 1394 |
} |
| 1395 |
} |
| 1396 |
|
| 1397 |
endif; |
| 1398 |
|