special/GatewayChooser.php
<?php
/**
* GatewayChooser acts as a gateway-agnostic landing page.
* When passed a country, currency, and payment method combination, it determines the
* appropriate gateway based on gateway configurations and priority rules.
*
* @author Damilare Adedoyin <dadedoyin@wikimedia.org>
* @author Elliott Eggleston <eeggleston@wikimedia.org>
* @author Wenjun Fan <wfan@wikimedia.org>
* @author Peter Gehres <pgehres@wikimedia.org>
* @author Jack Gleeson <jgleeson@wikimedia.org>
* @author Andrew Green <agreen@wikimedia.org>
* @author Katie Horn <khorn@wikimedia.org>
* @author Christine Stone <cstone@wikimedia.org>
* @author Matt Walker <mwalker@wikimedia.org>
*/
class GatewayChooser extends UnlistedSpecialPage {
/**
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
public function __construct() {
$this->logger = DonationLoggerFactory::getLoggerForType( 'GatewayAdapter', 'GatewayChooser' );
parent::__construct( 'GatewayChooser' );
}
public function execute( $par ) {
// Bow out if gateway chooser is not enabled
if ( !$this->getConfig()->get( 'DonationInterfaceEnableGatewayChooser' ) ) {
throw new BadTitleError();
}
// Also bow out if we're in maintenance mode
if ( $this->getConfig()->get( 'DonationInterfaceFundraiserMaintenance' ) ) {
$this->getOutput()->redirect( Title::newFromText( 'Special:FundraiserMaintenance' )->getFullURL(), '302' );
return;
}
// Get an associative array of params from the URL. The ones we look at to determine
// gateway options will be sanitized/vaidated, and the rest will be fetched verbatim.
$params = $this->getParamsFromURL();
if ( $params[ 'country' ] && $params[ 'payment_method' ] ) {
// Find possible gateways
$supportedGateways = $this->getSupportedGateways(
$params[ 'country' ],
$params[ 'currency' ],
$params[ 'payment_method' ],
$params[ 'payment_submethod' ],
$params[ 'recurring' ],
$params[ 'variant' ]
);
} else {
// redirect to ways to give
$this->logger->warning(
'Missing country or payment_method at GatewayChooser for query string ' .
$this->getRequest()->getRawQueryString() .
' and referer ' .
$this->getRequest()->getHeader( 'referer' )
);
$this->getOutput()->redirect( $this->getProblemRedirectUrl() );
return;
}
// If there are no supported gateways for these inputs, log an error and show an
// error page.
if ( count( $supportedGateways ) === 0 ) {
$this->logger->error(
'No supported gateway for parameters: ' . print_r( $params, true ) .
' from referrer ' . $this->getRequest()->getHeader( 'referer' ) );
$this->getOutput()->showErrorPage(
'donate_interface-error-msg-general',
'donate_interface-error-no-form',
[ $this->getConfig()->get( 'DonationInterfaceProblemsEmail' ) ]
);
return;
}
// If a specific gateway was requested and it's supported, choose it
if ( $params[ 'gateway' ] && in_array( $params[ 'gateway' ], $supportedGateways ) ) {
$selectedGateway = $params[ 'gateway' ];
} elseif ( count( $supportedGateways ) === 1 ) {
// If only one gateway is supported, choose it
$selectedGateway = $supportedGateways[ 0 ];
} else {
// We need to choose from among two or more supported gateways
$selectedGateway = $this->chooseGatewayByPriority( $supportedGateways, $params );
}
// Get the URL and perform the redirection
$redirectURL = self::buildGatewayPageURL( $selectedGateway, $params, $this->getConfig() );
$this->getOutput()->redirect( $redirectURL );
}
/**
* Build a URL to a payments form with the data that we have.
*
* @param string $gateway The short name of the payment gateway.
* @param array $params An array of params to send to the gateway page.
* @param Config $mwConfig MediaWiki Config
*
* @return string URL of special page for $gateway with $params on the querystring.
*/
public static function buildGatewayPageURL( string $gateway, array $params, Config $mwConfig ) {
// Remove empty strings (normally not expected)
$params = array_filter( $params, static function ( $v ) {
return $v !== '';
} );
// Add an appeal parameter if none was present
$params = array_merge( [
'appeal' => $mwConfig->get( 'DonationInterfaceDefaultAppeal' ),
], $params );
$specialPage = GatewayPage::getGatewayPageName( $gateway, $mwConfig );
return self::getTitleFor( $specialPage )->getLocalURL( $params );
}
/**
* Get all the gateways supported for the provided inputs.
*
* @param string $country
* @param string|null $currency
* @param string $paymentMethod
* @param string|null $paymentSubmethod
* @param bool $recurring
* @param string|null $variant
* @return array
*/
private function getSupportedGateways(
string $country,
?string $currency,
string $paymentMethod,
?string $paymentSubmethod,
bool $recurring,
?string $variant
): array {
$possibleGateways = [];
$mwConfig = $this->getConfig(); // Main MediaWiki config object, via superclass
$enabledGateways = GatewayAdapter::getEnabledGateways( $mwConfig );
// Loop over enabled gateways to find ones supported for these inputs
foreach ( $enabledGateways as $enabledGateway ) {
$gatewayConfig =
ConfigurationReader::createForGateway( $enabledGateway, $variant, $mwConfig )
->readConfiguration();
// TODO Knowledge about configuration layout should be encapsulated somewhere
// See https://phabricator.wikimedia.org/T291699
// Check availability for country; config is a flat array, and
// $country input and countries config are always expected.
if ( !in_array( $country, $gatewayConfig[ 'countries' ] ) ) {
continue;
}
// Check if we should include the gateway even if the currency is unsupported,
// and, if not, check availability for this currency.
// Currencies config is a flat array and is always expected.
if (
!$gatewayConfig[ 'general' ][ 'gateway_chooser' ][ 'still_include_if_currency_is_not_supported' ] &&
$currency &&
!in_array( $currency, $gatewayConfig[ 'currencies' ] )
) {
continue;
}
// Check availability for payment method, and, if requested, recurring;
// in config, payment methods codes are keys of the outer array, though
// payment_methods.yaml can also be empty.
// $paymentMethod input is always expected.
if ( !empty( $gatewayConfig[ 'payment_methods' ] ) ) {
$supportedPaymentMethods = $gatewayConfig[ 'payment_methods' ];
if ( !isset( $supportedPaymentMethods[ $paymentMethod ] ) ) {
// Specified payment method not supported for this gateway
continue;
}
// Check whether the payment method is restricted by country, and if so
// skip when the donor's country is not on the list
if (
isset( $supportedPaymentMethods[ $paymentMethod ][ 'countries' ] ) &&
!in_array( $country, $supportedPaymentMethods[ $paymentMethod ][ 'countries' ] )
) {
// Specified country not supported by payment method for this gateway
continue;
}
// Recurring availability for the payment method is indicated by a key
// on the associative array that is the value for the payment method
if (
$recurring &&
empty( $supportedPaymentMethods[ $paymentMethod ][ 'recurring' ] )
) {
// Specified payment method does not support recurring for this gateway
continue;
}
}
// When a submethod is specified, check to see whether it is supported, then
// whether it is restricted by country. If there are country restrictions, skip
// the gateway when the donor's country is not on the list.
if ( $paymentSubmethod && !empty( $gatewayConfig[ 'payment_submethods' ] ) ) {
$supportedSubmethods = $gatewayConfig[ 'payment_submethods' ];
if ( !isset( $supportedSubmethods[ $paymentSubmethod ] ) ) {
// Specified submethod not supported by gateway
continue;
}
// FIXME: submethod-level country restrictions are not a flat array of country
// codes like the lists for gateway and method-level restrictions, but an associative
// array of country code => bool. None are set to false in the shipped defaults.
// Switch that over to a flat array so we can use !in_array() rather than empty()
if (
isset( $supportedSubmethods[ $paymentSubmethod ][ 'countries' ] ) &&
empty( $supportedSubmethods[ $paymentSubmethod ][ 'countries' ][ $country ] )
) {
// Specified country not in submethod's country list for this gateway, or
// is present with a value of false.
continue;
}
}
$possibleGateways[] = $enabledGateway;
}
return $possibleGateways;
}
/**
* In here we're gonna check a predefined list of
* priority rules to see which of the supported gateways
* best fits the user parameters.
*
* Example rules would look like:
* $rules = [
* [
* 'conditions' => [ 'utm_medium' => 'endowment' ],
* 'gateways' => [ 'ingenico', 'paypal_ec' ]
* ],
* [
* 'conditions' => [
* 'payment_method' => 'cc',
* 'country' => [ 'NL', 'IL', 'FR' ]
* ],
* 'gateways' => [ 'adyen', 'ingenico' ]
* ],
* [
* # No conditions, this is treated as default.
* # Should be last in the list as it will always match.
* 'gateways' => [ 'ingenico', 'adyen', 'paypal_ec', 'amazon', 'dlocal' ]
* ]
* ];
*
* @param array $supportedGateways List of gateway codes assumed to
* support the requested country / currency / payment_method
* @param array $params Query-string parameters
* @return string|null Selected gateway code
*/
public function chooseGatewayByPriority( $supportedGateways, $params ) {
$rules = $this->getConfig()->get( 'DonationInterfaceGatewayPriorityRules' );
foreach ( $rules as $rule ) {
// Do our $params match all the conditions for this rule?
// A rule with no conditions will always be matched.
$ruleMatches = true;
if ( isset( $rule['conditions'] ) ) {
// Loop over all the conditions looking for any that don't match
foreach ( $rule['conditions'] as $conditionName => $conditionValue ) {
// If the key of a condition is not in the params, the rule does not match
if ( !isset( $params[$conditionName] ) ) {
$ruleMatches = false;
break;
}
// Condition value is a list, e.g. of countries
if ( is_array( $conditionValue ) ) {
if ( in_array( $params[$conditionName], $conditionValue ) ) {
continue;
} else {
$ruleMatches = false;
break;
}
}
// Condition value is a scalar, just check it against the param value
if ( $params[$conditionName] == $conditionValue ) {
continue;
} else {
$ruleMatches = false;
break;
}
}
}
if ( $ruleMatches ) {
// Find the first in the rule's gateways list which is in $supportedGateways
foreach ( $rule['gateways'] as $ruleGateway ) {
if ( in_array( $ruleGateway, $supportedGateways ) ) {
return $ruleGateway;
}
}
// Complain, this is fishy. If for example a rule states that all endowment donations
// should go to gateways X and Y, and we get to this point, it means an endowment
// donation has come in for a method or country not supported by gateways X or Y.
$conditionMessage = isset( $rule['conditions'] ) ?
'rule with conditions ' . print_r( $rule['conditions'], true ) :
'default rule';
$this->logger->warning(
'Matched ' . $conditionMessage . ' ' .
'and parameters ' . print_r( $params, true ) . ', but rule gateway list includes ' .
'none of supported gateways (' . implode( ',', $supportedGateways ) . '), ' .
' from referrer ' . $this->getRequest()->getHeader( 'referer' )
);
}
}
// We only had one supported gateway, but no rules matched or the matching rule didn't include
// the supported gateway. Dealing with this here rather than at top of method, so that we hit
// the code to log a warning if a matched rule points to an unsupported gateway.
if ( count( $supportedGateways ) === 1 ) {
return $supportedGateways[0];
}
// Multiple gateways supported, but no rule matched. Warn and return the first supported gateway.
if ( count( $supportedGateways ) > 1 ) {
$this->logger->warning(
'No rules matched parameters ' . print_r( $params, true ) .
' from referrer ' . $this->getRequest()->getHeader( 'referer' ) . '; arbitrarily ' .
'choosing from supported gateways (' . implode( ',', $supportedGateways ) . '). ' .
'Consider adding a default rule (one with no conditions) to the end of ' .
'$wgDonationInterfaceGatewayPriorityRules'
);
return $supportedGateways[0];
}
// No gateways were supported in the first place - return null and trigger an error page
return null;
}
/**
* Get params from the URL, sanitizing and, in some cases, validating the ones we
* use to get possible gateway, and including the rest verbatim.
*
* @return array associative array of params from the URL
*/
private function getParamsFromURL(): array {
// Get country code from request param, or, if it's not sent or is invalid, use
// geoip lookup
$country = $this->sanitizedValOrNull( 'country' );
if ( !CountryValidation::isValidIsoCode( $country ) ) {
$country = CountryValidation::lookUpCountry( $this->getRequest()->getIP() );
if ( !$country || !CountryValidation::isValidIsoCode( $country ) ) {
$this->logger->warning(
"GeoIP lookup returned invalid country '$country'!, " .
'from referrer ' . $this->getRequest()->getHeader( 'referer' ) );
}
}
// Get currency from request param. Also check for a value from the currency_code
// param, and if there is one, warn so we can track and remove these
$currency = $this->sanitizedValOrNull( 'currency' );
if ( !$currency ) {
$currency = $this->sanitizedValOrNull( 'currency_code' );
if ( $currency ) {
$this->logger->warning(
'Deprecated currency_code param from referrer ' .
$this->getRequest()->getHeader( 'referer' ) );
}
}
$paymentMethod = $this->sanitizedValOrNull( 'payment_method' );
// No payment method will cause an error a little further down
if ( !$paymentMethod ) {
$this->logger->warning(
'No payment method URL param from referrer ' .
$this->getRequest()->getHeader( 'referer' ) );
}
// For recurring, we'll interpret no URL param, 'false', '0' and '' as false.
// This follows legacy behavior, to ensure existing links work as expected.
// sanitizedValOrNull() will return null if the param is absent or ''
$recurringRawVal = $this->sanitizedValOrNull( 'recurring' );
// We map this to 0 or 1 rather than boolean true or false because the
// getLocalURL function we eventually feed this to will discard any param
// whose value is false.
if ( $recurringRawVal === 'false' ) {
$recurring = 0;
} elseif ( $recurringRawVal === 'true' ) {
$recurring = 1;
} else {
// map null to 0, as we want to affirmatively overwrite any recurring=1
// from a previous attempt if there is no recurring value on this URL.
$recurring = (int)$recurringRawVal;
}
// These are the parameters that are actually used to find possible gateways
$params = [
'country' => $country,
'currency' => $currency,
'payment_method' => $paymentMethod,
'payment_submethod' => $this->sanitizedValOrNull( 'payment_submethod' ),
'recurring' => $recurring,
'gateway' => $this->sanitizedValOrNull( 'gateway' ),
'variant' => $this->sanitizedValOrNull( 'variant' )
];
// All other URL parameters (except title and deprecated currency_code) will be
// passed through on the redirect URL without sanitization or validation
$passThruParams = [];
foreach ( $this->getRequest()->getValues() as $key => $value ) {
if ( !array_key_exists( $key, $params ) && $key !== 'title' && $key !== 'currency_code' ) {
$passThruParams[ $key ] = $value;
}
}
return $params + $passThruParams;
}
/**
* Get the sanitized string value of a URL parameter. If the parameter was not present
* or is an empty string, return null.
*
* @param string $paramName
* @return ?string
*/
private function sanitizedValOrNull( string $paramName ): ?string {
$val = $this->getRequest()->getVal( $paramName, null );
if ( $val === '' || $val === null ) {
return null;
}
$sanitizedVal = preg_replace( "/[^A-Za-z0-9_\-]+/", "", $val );
if ( $sanitizedVal !== $val ) {
$this->logger->warning(
"Unexpected characters in $paramName; sanitized value is $sanitizedVal, " .
'from referrer ' . $this->getRequest()->getHeader( 'referer' )
);
}
return $sanitizedVal;
}
protected function getProblemRedirectUrl(): string {
$problemsUrl = $this->getConfig()->get( 'DonationInterfaceChooserProblemURL' );
$queryValues = $this->getRequest()->getQueryValues() ?? [];
unset( $queryValues['title'] );
if ( count( $queryValues ) > 0 ) {
$glue = strpos( $problemsUrl, '?' ) === false ? '?' : '&';
$problemsUrl .= $glue . http_build_query( $queryValues );
}
return $problemsUrl;
}
}