gateway_common/GatewayPage.php
<?php
/**
* Wikimedia Foundation
*
* LICENSE
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
*/
use Psr\Log\LogLevel;
use SmashPig\Core\Logging\Logger;
use SmashPig\Core\PaymentError;
use SmashPig\PaymentData\FinalStatus;
/**
* GatewayPage
* This class is the generic unlisted special page in charge of actually
* displaying the form. Each gateway will have one or more direct descendants of
* this class, with most of the gateway-specific control logic in its handleRequest
* function. For instance: extensions/DonationInterface/ingenico_gateway/ingenico_gateway.body.php
*
* *** Constraint for implementing classes *** The special page name must always be the gateway
* adapter class name with 'Adapter' replaced with 'Gateway'.
*/
abstract class GatewayPage extends UnlistedSpecialPage {
/**
* flag for setting Monthly Convert modal on template
* @var bool
*/
public $supportsMonthlyConvert = false;
/**
* Derived classes must override this with the identifier of the gateway
* as set in GatewayAdapter::IDENTIFIER
* @var string
*/
protected $gatewayIdentifier;
/**
* The gateway adapter object
* @var GatewayAdapter
*/
public $adapter;
/**
* Gateway-specific logger
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* When true, display an error form rather than the standard payment form
* @var bool
*/
protected $showError = false;
/**
* Constructor
*/
public function __construct() {
$me = get_called_class();
parent::__construct( $me );
}
/**
* Show the special page
*
* @param string|null $par parameter passed to the page or null
*/
public function execute( $par ) {
// FIXME: Deprecate "language" param.
$language = $this->getRequest()->getVal( 'language' );
$this->showError = $this->getRequest()->getBool( 'showError' );
if ( !$language ) {
// For some result pages, language does not come in on a standard URL param
// (language or uselang). For those cases, it's pretty safe to assume the
// correct language is in session.
// FIXME Restrict the places where we access session data
$donorData = WmfFramework::getSessionValue( 'Donor' );
if ( ( $donorData !== null ) && isset( $donorData[ 'language' ] ) ) {
$language = $donorData[ 'language' ];
}
}
if ( $language ) {
$this->getContext()->setLanguage( $language );
}
if ( $this->getConfig()->get( 'DonationInterfaceFundraiserMaintenance' ) ) {
$this->getOutput()->redirect( Title::newFromText( 'Special:FundraiserMaintenance' )->getFullURL(), '302'
);
return;
}
$gatewayName = $this->getGatewayIdentifier();
$className = DonationInterface::getAdapterClassForGateway( $gatewayName );
$this->logger = DonationLoggerFactory::getLoggerForType(
$className,
$this->getLogPrefix()
);
$this->logger->debug(
'Loading page ' . get_called_class() .
' with request parameters ' . json_encode( $this->getRequest()->getQueryValues() )
);
DonationInterface::setSmashPigProvider( $gatewayName );
try {
$variant = $this->getVariant();
$this->adapter = new $className( [ 'variant' => $variant ] );
$this->logger = DonationLoggerFactory::getLogger( $this->adapter );
// FIXME: SmashPig should just use Monolog.
Logger::getContext()->enterContext( $this->adapter->getLogMessagePrefix() );
$out = $this->getOutput();
// @phan-suppress-next-line PhanUndeclaredMethod Removed in 1.41
$out->preventClickjacking();
$this->addThankYouPrefetch( $out );
// Use addModuleStyles to load these CSS rules in early and avoid
// a flash of MediaWiki elements.
$out->addModuleStyles( 'donationInterface.styles' );
$out->addModuleStyles( 'donationInterface.skinOverrideStyles' );
$out->addModules( 'donationInterface.skinOverride' );
// Stolen from Minerva skin
$out->addHeadItem( 'viewport',
Html::element(
'meta', [
'name' => 'viewport',
'content' => 'initial-scale=1.0, user-scalable=yes, minimum-scale=0.25, maximum-scale=5.0, width=device-width',
]
)
);
} catch ( Exception $ex ) {
$this->logger->error(
"Exception setting up GatewayPage with adapter class $className: " .
"{$ex->getMessage()}\n{$ex->getTraceAsString()}"
);
// Setup scrambled, no point in continuing
$this->displayFailPage();
return;
}
// FIXME: Should have checked this before creating the adapter.
if ( $this->adapter->getGlobal( 'Enabled' ) !== true ) {
$this->logger->info( 'Displaying fail page for disabled gateway' );
$this->displayFailPage();
return;
}
if ( $this->adapter->getFinalStatus() === FinalStatus::FAILED ) {
$this->logger->info( 'Displaying fail page for failed GatewayReady checks' );
$this->displayFailPage();
return;
}
try {
$this->handleRequest();
} catch ( Exception $ex ) {
$this->logger->error( "Displaying fail page for exception: " . $ex->getMessage() );
$this->displayFailPage();
return;
}
}
/**
* Tell the browser to look up the Thank You page DNS entry, to load
* it faster when the donor completes the donation.
*
* @param OutputPage $out
* @return void
*/
private function addThankYouPrefetch( OutputPage $out ): void {
$thankYouPage = $this->adapter::getGlobal( 'ThankYouPage' );
$urlParts = parse_url( $thankYouPage );
if ( $urlParts ) {
$out->addLink( [
'rel' => 'prefetch',
// @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset
'href' => $urlParts['scheme'] . '://' . $urlParts['host']
] );
}
}
/**
* Handle the donation request.
*
* FIXME: Be more disciplined about how handleRequest fits with
* handleDonationRequest. Would it be cleaner to move to a pre and post
* hook scheme?
*/
protected function handleRequest() {
$this->handleDonationRequest();
}
/**
* Build and display form to user
*/
public function displayForm() {
if ( $this->showError ) {
$form = new MustacheErrorForm();
} else {
$form = new Gateway_Form_Mustache();
}
$form->setGateway( $this->adapter );
$form->setGatewayPage( $this );
$formHtml = $form->getForm();
$output = $this->getOutput();
if ( !$this->showError ) {
// Only register the setClientVariables callback if we're loading a payments form.
// Error forms don't load any gateway-specific scripts so don't need these variables.
// And if we're already in an error condition (like an invalid payment method),
// calling setClientVariables could throw an exception. We set this hook after
// $form->getForm has succeeded so we avoid registering it when that function
// throws an error
$this->getHookContainer()->register(
'MakeGlobalVariablesScript', [ $this, 'setClientVariablesWithErrorHandling' ]
);
// Also only load any external gateway scripts for payments forms.
$this->addGatewaySpecificResources( $output );
}
$output->addModules( $form->getResources() );
$output->addModuleStyles( $form->getStyleModules() );
$output->addHTML( $formHtml );
}
/**
* Display a failure page
*/
public function displayFailPage() {
if ( $this->adapter ) {
$this->showError = true;
$this->displayForm();
} else {
$output = $this->getOutput();
if ( !is_callable( [ $output, 'setPageTitleMsg' ] ) ) {
// Backward compatibility with MW < 1.41
$output->prepareErrorPage( $this->msg( 'donate_interface-error-msg-general' ) );
} else {
// MW >= 1.41
$output->prepareErrorPage();
$output->setPageTitleMsg( $this->msg( 'donate_interface-error-msg-general' ) );
}
// @phan-suppress-next-line SecurityCheck-XSS Message is in RawHtmlMessages config
$output->addHTML( $this->msg(
'donate_interface-otherways',
[ $this->getConfig()->get( 'DonationInterfaceOtherWaysURL' ) ]
)->plain() );
}
}
/**
* Get the current adapter class
* @return string containing the chosen adapter class name
*
* Override if your page selects between multiple adapters based on
* context.
*/
protected function getGatewayIdentifier() {
return $this->gatewayIdentifier;
}
/**
* displayResultsForDebug
*
* Displays useful information for debugging purposes.
* Enable with $wgDonationInterfaceDisplayDebug, or the adapter equivalent.
* @param PaymentTransactionResponse|null $results
* @return null
*/
protected function displayResultsForDebug( ?PaymentTransactionResponse $results = null ) {
$results = !$results ? $this->adapter->getTransactionResponse() : $results;
if ( $this->adapter->getGlobal( 'DisplayDebug' ) !== true ) {
return;
}
$output = $this->getOutput();
$output->addHTML( Html::element( 'span', [], $results->getMessage() ) );
$errors = $results->getErrors();
if ( $errors ) {
$output->addHTML( Html::openElement( 'ul' ) );
foreach ( $errors as $code => $value ) {
$output->addHTML( Html::element( 'li', [], "Error $code: " . print_r( $value, true ) ) );
}
$output->addHTML( Html::closeElement( 'ul' ) );
}
$data = $results->getData();
if ( $data ) {
$output->addHTML( Html::openElement( 'ul' ) );
foreach ( $data as $key => $value ) {
if ( is_array( $value ) ) {
$output->addHTML( Html::openElement( 'li', [] ) . Html::openElement( 'ul' ) );
foreach ( $value as $key2 => $val2 ) {
$output->addHTML( Html::element( 'li', [], "$key2: $val2" ) );
}
$output->addHTML( Html::closeElement( 'ul' ) . Html::closeElement( 'li' ) );
} else {
$output->addHTML( Html::element( 'li', [], "$key: $value" ) );
}
}
$output->addHTML( Html::closeElement( 'ul' ) );
} else {
$output->addHTML( "Empty Results" );
}
$donorData = $this->getRequest()->getSessionData( 'Donor' );
if ( is_array( $donorData ) ) {
$output->addHTML( "Session Donor Vars:" . Html::openElement( 'ul' ) );
foreach ( $donorData as $key => $val ) {
$output->addHTML( Html::element( 'li', [], "$key: $val" ) );
}
$output->addHTML( Html::closeElement( 'ul' ) );
} else {
$output->addHTML( "No Session Donor Vars:" );
}
if ( is_array( $this->adapter->debugarray ) ) {
$output->addHTML( "Debug Array:" . Html::openElement( 'ul' ) );
foreach ( $this->adapter->debugarray as $val ) {
// @phan-suppress-next-line SecurityCheck-DoubleEscaped Unclear what stored in debugarray
$output->addHTML( Html::element( 'li', [], $val ) );
}
$output->addHTML( Html::closeElement( 'ul' ) );
} else {
$output->addHTML( "No Debug Array" );
}
}
/**
* Respond to a donation request
*/
protected function handleDonationRequest() {
$this->setHeaders();
// TODO: This is where we should feed GPCS parameters into the gateway
// and DonationData, rather than harvest params in the adapter itself.
// dispatch forms/handling
if ( $this->adapter->checkTokens() ) {
if ( $this->isProcessImmediate() ) {
// Check form for errors
$validated_ok = $this->adapter->validatedOK();
// Proceed to the next step, unless there were errors.
if ( $validated_ok ) {
// Attempt to process the payment, then render the response.
$this->processPayment();
} else {
// Redisplay form to give the donor notification and a
// chance correct their errors.
$this->displayForm();
}
} else {
$this->adapter->session_addDonorData();
$this->displayForm();
}
} else { // token mismatch
$this->adapter->getErrorState()->addError( new PaymentError(
'internal-0001',
'Failed CSRF token validation',
LogLevel::INFO
) );
$this->displayForm();
}
}
/**
* Determine if we should attempt to process the payment now
*
* @return bool True if we should attempt processing.
*/
protected function isProcessImmediate() {
// If the user posted to this form, process immediately.
if ( $this->adapter->posted ) {
return true;
}
// Otherwise, respect the "redirect" parameter. If it is "1", try to
// skip the interstitial page. If it's "0", do not process immediately.
$redirect = $this->adapter->getData_Unstaged_Escaped( 'redirect' );
if ( $redirect !== null ) {
return ( $redirect === '1' || $redirect === 'true' );
}
return false;
}
/**
* Ask the adapter to perform a payment
*
* Route the donor based on the response.
*/
protected function processPayment() {
$this->renderResponse( $this->adapter->doPayment() );
}
/**
* Take UI action suggested by the payment result
* @param PaymentResult $result returned by gateway adapter's doPayment
* or processDonorReturn function
*/
protected function renderResponse( PaymentResult $result ) {
if ( $result->isFailed() ) {
$this->logger->info( 'Displaying fail page for failed PaymentResult' );
$this->displayFailPage();
return;
}
$url = $result->getRedirect();
if ( $url ) {
$this->adapter->logPending();
$this->getOutput()->redirect( $url );
return;
}
$url = $result->getIframe();
if ( $url ) {
// Show a form containing an iframe.
// Well, that's sketchy. See TODO in renderIframe: we should
// accomplish this entirely by passing an iframeSrcUrl parameter
// to the template.
$this->displayForm();
$this->renderIframe( $url );
return;
}
if ( count( $result->getErrors() ) ) {
$this->displayForm();
return;
}
if ( $this->adapter->showMonthlyConvert() ) {
$this->logger->info( "Displaying monthly convert modal after successful one-time donation PaymentResult" );
$this->displayForm();
return;
}
// Success.
$thankYouPage = ResultPages::getThankYouPage( $this->adapter );
$this->logger->info( "Displaying thank you page $thankYouPage for successful PaymentResult." );
$this->getOutput()->redirect( $thankYouPage );
}
/**
* Append iframe
*
* TODO: Should be rendered by the template.
*
* @param string $url
*/
protected function renderIframe( $url ) {
$attrs = [
'id' => 'paymentiframe',
'name' => 'paymentiframe',
'width' => '680',
'height' => '300'
];
$attrs['frameborder'] = '0';
$attrs['style'] = 'display:block;';
$attrs['src'] = $url;
$paymentFrame = Xml::openElement( 'iframe', $attrs );
$paymentFrame .= Xml::closeElement( 'iframe' );
$this->getOutput()->addHTML( $paymentFrame );
}
/**
* Try to get donor information to tag log entries in case we don't
* have an adapter instance.
* @return string
*/
protected function getLogPrefix() {
$info = [];
$donorData = $this->getRequest()->getSessionData( 'Donor' );
if ( is_array( $donorData ) ) {
if ( isset( $donorData['contribution_tracking_id'] ) ) {
$info[] = $donorData['contribution_tracking_id'];
}
if ( isset( $donorData['order_id'] ) ) {
$info[] = $donorData['order_id'];
}
}
return implode( ':', $info ) . ' ';
}
public function setHeaders() {
parent::setHeaders();
// TODO: Switch title according to failiness.
// Maybe ask $form_obj for a title so different errors can show different titles
$output = $this->getOutput();
$titleMsg = $this->msg( 'donate_interface-make-your-donation' );
if ( !is_callable( [ $output, 'setPageTitleMsg' ] ) ) {
// Backward compatibility with MW < 1.41
$output->setPageTitle( $titleMsg );
} else {
// MW >= 1.41
$output->setPageTitleMsg( $titleMsg );
}
}
public function setClientVariablesWithErrorHandling( &$vars ) {
try {
$this->setClientVariables( $vars );
} catch ( Exception $ex ) {
$this->logger->error(
"Redirecting to fail page for exception in setClientVariables: " . $ex->getMessage()
);
// At this point in the special page lifecycle the payments form has already been
// sent to the outputPage so we can't use displayFailPage. It seems like we can't
// even redirect at this point ($this->getOutput()->redirect fails here, I guess
// because the headers have been finalized). So we just manipulate the $vars to
// tell the javascript to show the error.
$vars['DonationInterfaceSetClientVariablesError'] = true;
$request = $this->getRequest();
$hasParams = count( $request->getQueryValuesOnly() ) > 0;
$vars['DonationInterfaceFailUrl'] = $request->getFullRequestURL() .
( $hasParams ? '&' : '?' ) . 'showError=true';
}
}
/**
* MakeGlobalVariablesScript handler, sends settings to Javascript
* @param array &$vars
*/
public function setClientVariables( &$vars ) {
$language = $this->adapter->getData_Unstaged_Escaped( 'language' );
$country = $this->adapter->getData_Unstaged_Escaped( 'country' );
$vars['wgDonationInterfaceAmountRules'] = $this->adapter->getDonationRules();
$vars['wgDonationInterfaceLogDebug'] = $this->adapter->getGlobal( 'LogDebug' );
if ( $this->adapter->showMonthlyConvert() ) {
$thankYouUrl = ResultPages::getThankYouPage( $this->adapter );
$vars['wgDonationInterfaceThankYouUrl'] = $thankYouUrl;
$vars['showMConStartup'] = $this->getRequest()->getBool( 'debugMonthlyConvert' );
$vars['wgDonationInterfaceMonthlyConvertAmounts'] = $this->adapter->getMonthlyConvertAmounts();
}
if ( $this->adapter->getGlobal( 'LogClientErrors' ) ) {
// This variable is used by ext.donationInterface.errorLog, added to the page in
// Gateway_Form_Mustache::getResources
$vars['wgDonationInterfaceClientErrorLogIgnorePatterns'] = $this->adapter->getGlobal(
'ClientErrorLogIgnorePatterns'
);
}
try {
$clientRules = $this->adapter->getClientSideValidationRules();
if ( $clientRules ) {
// Translate all the messages
// FIXME: figure out country fallback add the i18n strings
// for use with client-side mw.msg()
foreach ( $clientRules as &$fieldRules ) {
foreach ( $fieldRules as &$rule ) {
if ( !empty( $rule['messageKey'] ) ) {
$rule['message'] = MessageUtils::getCountrySpecificMessage(
$rule['messageKey'],
$country,
$language
);
}
}
}
$vars['wgDonationInterfaceValidationRules'] = $clientRules;
}
} catch ( Exception $ex ) {
$this->logger->warning(
'Caught exception setting client-side validation rules: ' .
$ex->getMessage()
);
}
}
protected function getVariant() {
// FIXME: This is the sort of thing DonationData is supposed to do,
// but we construct it too late to use variant in the configuration
// reader. We should be pulling all the get / post / session variables
// up here in the page class before creating the adapter.
$variant = $this->getRequest()->getVal( 'variant' );
if ( !$variant ) {
$donorData = $this->getRequest()->getSessionData( 'Donor' );
if ( $donorData && !empty( $donorData['variant'] ) ) {
$variant = $donorData['variant'];
}
}
return $variant;
}
/**
* Integrations that do not show submethod buttons should override to return false.
*
* @return bool
*/
public function showSubmethodButtons() {
return true;
}
/**
* Integrations that never need a continue button should override to return false.
*
* @return bool
*/
public function showContinueButton() {
return true;
}
/**
* Get the name of the special page for a gateway.
*
* @param string $gatewayId
* @param Config $mwConfig MediaWiki Config
* @return string
*/
public static function getGatewayPageName( string $gatewayId, Config $mwConfig ): string {
$gatewayClasses = $mwConfig->get( 'DonationInterfaceGatewayAdapters' );
// T302939: in order to pass the SpecialPageFatalTest::testSpecialPageDoesNotFatal unit test
// since no aliases are defined for those TestingAdapters
// will remove below if condition once those TestingAdapter gone from the test cases
if ( str_starts_with( $gatewayClasses[ $gatewayId ], 'Testing' ) ) {
$specialPage = 'GatewayChooser';
} else {
// The special page name is the gateway adapter class name with 'Adapter'
// replaced with 'Gateway'.
$specialPage = str_replace(
'Adapter',
'Gateway',
$gatewayClasses[ $gatewayId ]
);
}
return $specialPage;
}
/**
* Override this to add any gateway-specific scripts or stylesheets that can't
* be loaded via ResourceLoader (gateway-specific ResourceLoader modules are
* listed in ui_modules.yaml and loaded in Gateway_Form_Mustache::getResources).
* For example, scripts hosted by the payment provider could be added here.
* @param OutputPage $out
*/
protected function addGatewaySpecificResources( OutputPage $out ): void {
}
}