Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions ext/civi_contribute/Civi/Contribute/Service/CreateContribution.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,28 @@ public function saveNewContribution(AfformSubmitEvent $event) {
private function getLineItemsForRecord(string $entityType, array $values, array $priceFields): array {
$lineItems = [];

// Authoritative gate for admin-visibility (non-public) price options.
// These stay in the option list (see PriceFieldUtils::fetchPriceFieldSpecs)
// and are hidden client-side by an af-if, but the client is not trusted:
// reject a restricted option submitted by a user who may not select it.
// Mirrors CRM_Contribute_Form_Contribution_Main::buildPriceSet().
$restrictedOptionIds = PriceFieldUtils::getRestrictedPriceFieldValueIds();
$mayUseRestricted = !$restrictedOptionIds || \CRM_Core_Permission::check('edit contributions');

foreach ($values as $key => $fieldValue) {
$priceField = array_find($priceFields, fn ($priceField) => $priceField['name'] === $key);
if (!$priceField) {
continue;
}
// $fieldValue can be scalar or array
foreach ((array) $fieldValue as $singleFieldValue) {
// Only guard genuine option selections (a PFV id present in this
// field's option list) - never a quantity/amount entered on a
// qty or Default Contribution Amount field.
$isOption = isset($priceField['options']) && \array_key_exists($singleFieldValue, $priceField['options']);
if ($isOption && !$mayUseRestricted && \in_array((int) $singleFieldValue, $restrictedOptionIds, TRUE)) {
throw new \CRM_Core_Exception(E::ts('You are not permitted to select one of the chosen options.'));
}
$lineItems[] = PriceFieldUtils::getLineItemForPriceFieldValue($entityType, $values['id'] ?? NULL, $priceField, $singleFieldValue);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

namespace Civi\Contribute\Service;

use Civi\Afform\Event\AfformPrefillEvent;
use Civi\Api4\Action\Afform\Prefill;
use Civi\Api4\Generic\Result;
use Civi\Contribute\Utils\PriceFieldUtils;
use Civi\Core\Service\AutoService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
* Publishes `has_all_price_options` onto each price-bearing entity in the
* Afform.prefill response so the client-side af-if can decide whether to show
* admin-visibility (non-public) price options.
*
* The flag is purely permission-derived: TRUE when the current user holds
* 'edit contributions' (the same gate core QuickForm uses in
* CRM_Contribute_Form_Contribution_Main::buildPriceSet), FALSE otherwise. It
* is computed server-side, so the browser cannot assert it - and even if it
* were tampered with, the option is only revealed client-side; the authoritative
* check is CreateContribution::getLineItemsForRecord, which re-checks the
* permission and rejects a restricted value regardless.
*
* Two events:
* civi.afform.prefill (priority -10):
* Per price-bearing entity - stash the flag keyed by entity name.
* civi.api.respond (priority 0):
* Per API call - inject stashed facts into Afform.prefill responses.
*
* Synthetic facts don't survive Submit::preprocessSubmittedValues; the
* server-side enforcement re-checks the permission directly.
*
* @service civi.contribute.price_option_availability_publisher
*/
class PriceOptionAvailabilityPublisher extends AutoService implements EventSubscriberInterface {

private const FLAG = PriceFieldUtils::RESTRICTED_OPTIONS_FLAG;

private array $factsByRequest = [];

public static function getSubscribedEvents(): array {
return [
'civi.afform.prefill' => ['onAfformPrefill', -10],
'civi.api.respond' => ['onApiRespond', 0],
];
}

public function onAfformPrefill(AfformPrefillEvent $event): void {
// Nothing to reveal if there are no admin-visibility options anywhere.
if (!PriceFieldUtils::getRestrictedPriceFieldValueIds()) {
return;
}
// Only price-bearing entities carry these options.
if (!in_array($event->getEntityType(), PriceFieldUtils::getEnabledEntities(), TRUE)) {
return;
}

$requestId = spl_object_id($event->getApiRequest());
$this->factsByRequest[$requestId][$event->getEntityName()] = [
self::FLAG => \CRM_Core_Permission::check('edit contributions'),
];
}

public function onApiRespond($event): void {
$apiRequest = $event->getApiRequest();
if (!($apiRequest instanceof Prefill)) {
return;
}
$requestId = spl_object_id($apiRequest);
$facts = $this->factsByRequest[$requestId] ?? NULL;
unset($this->factsByRequest[$requestId]);

if (!$facts) {
return;
}
$response = $event->getResponse();
if (!$response instanceof Result) {
return;
}

// The Result is an ArrayObject - getArrayCopy + exchangeArray is the
// supported mutation pattern.
$values = $response->getArrayCopy();

// Index existing response entries by entity name for quick lookup.
// Entries are missing from the response when no record was loaded for
// that entity (e.g. a fresh "create" form has no autofill).
$indexByName = [];
foreach ($values as $i => $entry) {
if (isset($entry['name'])) {
$indexByName[$entry['name']] = $i;
}
}

foreach ($facts as $entityName => $entityFacts) {
if (!isset($indexByName[$entityName])) {
// Entity has no response entry - append one so the flag is present
// on the first (created-from-scratch) record.
$values[] = [
'name' => $entityName,
'values' => [['fields' => $entityFacts, 'joins' => []]],
];
continue;
}

$i = $indexByName[$entityName];
if (empty($values[$i]['values'])) {
$values[$i]['values'] = [['fields' => [], 'joins' => []]];
}
foreach ($values[$i]['values'] as $idx => $record) {
if (!isset($values[$i]['values'][$idx]['fields'])) {
$values[$i]['values'][$idx]['fields'] = [];
}
foreach ($entityFacts as $field => $value) {
$values[$i]['values'][$idx]['fields'][$field] = $value;
}
}
}
$response->exchangeArray($values);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<?php

namespace Civi\Contribute\Service;

use Civi\Contribute\Utils\PriceFieldUtils;
use Civi\Core\Service\AutoService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
* Attaches an `if:` rule to admin-visibility (non-public) price options in
* <af-field> defn.options, so they are hidden client-side from users who may
* not see them.
*
* Admin price options stay in the option list (see
* PriceFieldUtils::fetchPriceFieldSpecs) because they are real, selectable
* options for privileged users. The baked afform markup is shared across all
* users (it is cached), so we cannot decide visibility here; instead we tag
* each restricted option with:
*
* if: [['<entity>[0][fields][has_all_price_options]', 'IS NOT EMPTY']]
*
* The `has_all_price_options` flag is published per-user at prefill time by
* PriceOptionAvailabilityPublisher. When it is absent/empty (the default, and
* the case for every user without 'edit contributions') the option is hidden.
*
* Client-side hiding is a UX affordance only: the authoritative gate is
* CreateContribution::getLineItemsForRecord, which re-checks the permission
* and rejects a restricted value regardless of what the browser submits.
*
* Runs at priority -100 so it executes AFTER AfformMetadataInjector (default
* priority 0) has populated defn.options - lower priority = later in Symfony
* EventDispatcher. Mirrors civicrm-payflowpro's OptionDefnInjector.
*
* @service civi.contribute.price_option_defn_injector
*/
class PriceOptionDefnInjector extends AutoService implements EventSubscriberInterface {

public static function getSubscribedEvents(): array {
return [
'hook_civicrm_alterAngular' => ['preprocess', -100],
];
}

/**
* @param \Civi\Core\Event\GenericHookEvent $e
*
* @see CRM_Utils_Hook::alterAngular()
*/
public function preprocess(\Civi\Core\Event\GenericHookEvent $e): void {
$restrictedIds = PriceFieldUtils::getRestrictedPriceFieldValueIds();
if (!$restrictedIds) {
return;
}
// Restrict the walk to fields that are actually price fields, so we never
// mistake an unrelated field's option id for a PriceFieldValue id.
$priceFieldNames = self::getPriceFieldNames();
if (!$priceFieldNames) {
return;
}
// Fast lookup sets.
$restricted = array_fill_keys($restrictedIds, TRUE);

$changeSet = \Civi\Angular\ChangeSet::create('priceOptionConditionals')
->alterHtml(';\\.aff\\.html$;', function ($doc, $path) use ($priceFieldNames, $restricted) {
foreach (pq('af-field', $doc) as $afField) {
/** @var \DOMElement $afField */
$name = $afField->getAttribute('name');
if ($name === '') {
continue;
}
// The af-field name may carry a ':name' pseudoconstant suffix or a
// ','-joined range; the price-field spec name has neither. Normalise
// the same way core does (strip the comma-join, then the suffix)
// before matching.
$baseName = explode(':', explode(',', $name)[0])[0];
if (!isset($priceFieldNames[$baseName])) {
continue;
}
$this->amendField($afField, $restricted);
}
});
$e->angular->add($changeSet);
}

/**
* Read the field's defn, attach the visibility `if:` to any option whose id
* is a restricted (admin) PriceFieldValue, write defn back. No-op if the
* field has no options or none are restricted.
*
* @param \DOMElement $afField
* @param array<int,true> $restricted
* Restricted PriceFieldValue ids as a lookup set.
*
* @throws \Exception
*/
protected function amendField(\DOMElement $afField, array $restricted): void {
$existing = trim(pq($afField)->attr('defn') ?: '');
// If the markup author wrote a non-object defn, leave it alone - same
// posture as AfformMetadataInjector::setFieldMetadata.
if ($existing && $existing[0] !== '{') {
return;
}

$rawDefn = $existing ? \CRM_Utils_JS::getRawProps($existing) : [];
if (empty($rawDefn['options'])) {
return;
}

$options = \CRM_Utils_JS::decode($rawDefn['options']);
if (!is_array($options)) {
return;
}

// Resolve the containing entity name so the af-if can read the flag off
// the right record (e.g. Contribution1[0][fields][has_all_price_options]).
$entityName = pq($afField)->parents('[af-fieldset]')->attr('af-fieldset');
if (!$entityName) {
return;
}
$lhs = $entityName . '[0][fields][' . PriceFieldUtils::RESTRICTED_OPTIONS_FLAG . ']';

$changed = FALSE;
foreach ($options as &$opt) {
// Options here are the verbose [{id, label, ...}] form (afform loads
// them with loadOptions => [id, label, ...]).
if (!isset($opt['id']) || !empty($opt['if'])) {
continue;
}
if (isset($restricted[(int) $opt['id']])) {
$opt['if'] = [[$lhs, 'IS NOT EMPTY']];
$changed = TRUE;
}
}
unset($opt);

if (!$changed) {
return;
}
$rawDefn['options'] = \CRM_Utils_JS::encode($options);
pq($afField)->attr('defn', htmlspecialchars(\CRM_Utils_JS::writeObject($rawDefn), ENT_COMPAT));
}

/**
* All price-field full names (across every price-bearing entity), as a
* lookup set keyed by name. These are the only <af-field> names whose
* option ids are PriceFieldValue ids.
*
* @return array<string, true>
*/
protected static function getPriceFieldNames(): array {
$names = [];
foreach (PriceFieldUtils::getPriceFieldSpecs() as $entitySpecs) {
foreach (array_keys($entitySpecs) as $fullName) {
$names[$fullName] = TRUE;
}
}
return $names;
}

}
42 changes: 42 additions & 0 deletions ext/civi_contribute/Civi/Contribute/Utils/PriceFieldUtils.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

class PriceFieldUtils {

/**
* Synthetic afform field that carries whether the current user may see
* admin-visibility (non-public) price options. Published per price-bearing
* entity by PriceOptionAvailabilityPublisher and referenced by the af-if
* that PriceOptionDefnInjector attaches to restricted options.
*/
const RESTRICTED_OPTIONS_FLAG = 'has_all_price_options';

/**
* @return string[] entities for which payments are enabled
*/
Expand Down Expand Up @@ -42,13 +50,47 @@ public static function getPriceFieldsForEntity(string $entity): array {
return self::getPriceFieldSpecs()[$entity] ?? [];
}

/**
* IDs of active PriceFieldValues whose visibility is "admin".
*
* These are legitimate, selectable options that must only be offered to -
* and accepted from - users who may see admin price options. This mirrors
* the option-level gate core QuickForm applies in
* CRM_Contribute_Form_Contribution_Main::buildPriceSet() (which drops admin
* options for users lacking 'edit contributions').
*
* This is the authoritative server-side allow-list, used both to inject the
* client-side af-if that hides these options and to reject a crafted
* submission from an unprivileged user.
*
* @return int[]
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
public static function getRestrictedPriceFieldValueIds(): array {
$cacheKey = __CLASS__ . '::restrictedPriceFieldValueIds';
if (!isset(\Civi::$statics[$cacheKey])) {
\Civi::$statics[$cacheKey] = array_map('intval', (array) \Civi\Api4\PriceFieldValue::get(FALSE)
->addSelect('id')
->addWhere('is_active', '=', TRUE)
->addWhere('visibility_id:name', '=', 'admin')
->execute()
->column('id'));
}
return \Civi::$statics[$cacheKey];
}

public static function getPriceFieldSpecs(): array {
if (!isset(\Civi::$statics[__CLASS__])) {
\Civi::$statics[__CLASS__] = self::fetchPriceFieldSpecs();
}
return \Civi::$statics[__CLASS__];
}

/**
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
protected static function fetchPriceFieldSpecs(): array {
$priceFields = (array) \Civi\Api4\PriceField::get(FALSE)
->addSelect('id', 'name', 'label', 'html_type', 'is_enter_qty', 'is_display_amounts')
Expand Down
Loading