silverstripe-userforms/code/Control/UserDefinedFormController.php
Will Rossiter 18eccb65d4
FIX Log any email exceptions gracefully
If an email send() generates any errors such as invalid template or API exceptions then capture the error in the logs rather than displaying the error to the user.
2021-06-22 21:54:06 +12:00

604 lines
23 KiB
PHP

<?php
namespace SilverStripe\UserForms\Control;
use Exception;
use PageController;
use Psr\Log\LoggerInterface;
use SilverStripe\AssetAdmin\Controller\AssetAdmin;
use SilverStripe\Assets\File;
use SilverStripe\Assets\Upload;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Email\Email;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Manifest\ModuleLoader;
use SilverStripe\Forms\Form;
use SilverStripe\i18n\i18n;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\ValidationException;
use SilverStripe\ORM\ValidationResult;
use SilverStripe\Security\Security;
use SilverStripe\UserForms\Extension\UserFormFileExtension;
use SilverStripe\UserForms\Form\UserForm;
use SilverStripe\UserForms\Model\EditableFormField;
use SilverStripe\UserForms\Model\EditableFormField\EditableFileField;
use SilverStripe\UserForms\Model\Submission\SubmittedForm;
use SilverStripe\UserForms\Model\Submission\SubmittedFileField;
use SilverStripe\UserForms\Model\UserDefinedForm;
use SilverStripe\View\ArrayData;
use SilverStripe\View\Requirements;
use SilverStripe\View\SSViewer;
use SilverStripe\View\ViewableData;
use Swift_RfcComplianceException;
/**
* Controller for the {@link UserDefinedForm} page type.
*
* @package userforms
*/
class UserDefinedFormController extends PageController
{
private static $finished_anchor = '#uff';
private static $allowed_actions = [
'index',
'ping',
'Form',
'finished',
];
/** @var string The name of the folder where form submissions will be placed by default */
private static $form_submissions_folder = 'Form-submissions';
protected function init()
{
parent::init();
$page = $this->data();
// load the css
if (!$page->config()->get('block_default_userforms_css')) {
Requirements::css('silverstripe/userforms:client/dist/styles/userforms.css');
}
// load the jquery
if (!$page->config()->get('block_default_userforms_js')) {
Requirements::javascript('silverstripe/userforms:client/dist/js/jquery.min.js');
Requirements::javascript(
'silverstripe/userforms:client/dist/js/jquery-validation/jquery.validate.min.js'
);
Requirements::javascript('silverstripe/admin:client/dist/js/i18n.js');
Requirements::add_i18n_javascript('silverstripe/userforms:client/lang');
Requirements::javascript('silverstripe/userforms:client/dist/js/userforms.js');
$this->addUserFormsValidatei18n();
// Bind a confirmation message when navigating away from a partially completed form.
if ($page::config()->get('enable_are_you_sure')) {
Requirements::javascript(
'silverstripe/userforms:client/dist/js/jquery.are-you-sure/jquery.are-you-sure.js'
);
}
}
}
/**
* Add the necessary jQuery validate i18n translation files, either by locale or by langauge,
* e.g. 'en_NZ' or 'en'. This adds "methods_abc.min.js" as well as "messages_abc.min.js" from the
* jQuery validate thirdparty library from dist/js.
*/
protected function addUserFormsValidatei18n()
{
$module = ModuleLoader::getModule('silverstripe/userforms');
$candidates = [
i18n::getData()->langFromLocale(i18n::config()->get('default_locale')),
i18n::config()->get('default_locale'),
i18n::getData()->langFromLocale(i18n::get_locale()),
i18n::get_locale(),
];
foreach ($candidates as $candidate) {
foreach (['messages', 'methods'] as $candidateType) {
$localisationCandidate = "client/dist/js/jquery-validation/localization/{$candidateType}_{$candidate}.min.js";
$resource = $module->getResource($localisationCandidate);
if ($resource->exists()) {
Requirements::javascript($resource->getRelativePath());
}
}
}
}
/**
* Using $UserDefinedForm in the Content area of the page shows
* where the form should be rendered into. If it does not exist
* then default back to $Form.
*
* @return array
*/
public function index(HTTPRequest $request = null)
{
$form = $this->Form();
if ($this->Content && $form && !$this->config()->disable_form_content_shortcode) {
$hasLocation = stristr($this->Content, '$UserDefinedForm');
if ($hasLocation) {
/** @see Requirements_Backend::escapeReplacement */
$formEscapedForRegex = addcslashes($form->forTemplate(), '\\$');
$content = preg_replace(
'/(<p[^>]*>)?\\$UserDefinedForm(<\\/p>)?/i',
$formEscapedForRegex,
$this->Content
);
return [
'Content' => DBField::create_field('HTMLText', $content),
'Form' => ''
];
}
}
return [
'Content' => DBField::create_field('HTMLText', $this->Content),
'Form' => $this->Form()
];
}
/**
* Keep the session alive for the user.
*
* @return int
*/
public function ping()
{
return 1;
}
/**
* Get the form for the page. Form can be modified by calling {@link updateForm()}
* on a UserDefinedForm extension.
*
* @return Form
*/
public function Form()
{
$form = UserForm::create($this, 'Form_' . $this->ID);
/** @skipUpgrade */
$form->setFormAction(Controller::join_links($this->Link(), 'Form'));
$this->generateConditionalJavascript();
return $form;
}
/**
* Generate the javascript for the conditional field show / hiding logic.
*
* @return void
*/
public function generateConditionalJavascript()
{
$rules = '';
$form = $this->data();
if (!$form) {
return;
}
$formFields = $form->Fields();
$watch = [];
if ($formFields) {
/** @var EditableFormField $field */
foreach ($formFields as $field) {
if ($result = $field->formatDisplayRules()) {
$watch[] = $result;
}
}
}
if ($watch) {
$rules .= $this->buildWatchJS($watch);
}
// Only add customScript if $default or $rules is defined
if ($rules) {
Requirements::customScript(<<<JS
(function($) {
$(document).ready(function() {
{$rules}
});
})(jQuery);
JS
, 'UserFormsConditional-' . $form->ID);
}
}
/**
* Process the form that is submitted through the site
*
* {@see UserForm::validate()} for validation step prior to processing
*
* @param array $data
* @param Form $form
*
* @return HTTPResponse
*/
public function process($data, $form)
{
$submittedForm = SubmittedForm::create();
$submittedForm->SubmittedByID = Security::getCurrentUser() ? Security::getCurrentUser()->ID : 0;
$submittedForm->ParentClass = get_class($this->data());
$submittedForm->ParentID = $this->ID;
// if saving is not disabled save now to generate the ID
if (!$this->DisableSaveSubmissions) {
$submittedForm->write();
}
$attachments = [];
$submittedFields = ArrayList::create();
foreach ($this->data()->Fields() as $field) {
if (!$field->showInReports()) {
continue;
}
$submittedField = $field->getSubmittedFormField();
$submittedField->ParentID = $submittedForm->ID;
$submittedField->Name = $field->Name;
$submittedField->Title = $field->getField('Title');
// save the value from the data
if ($field->hasMethod('getValueFromData')) {
$submittedField->Value = $field->getValueFromData($data);
} else {
if (isset($data[$field->Name])) {
$submittedField->Value = $data[$field->Name];
}
}
if (!empty($data[$field->Name])) {
if (in_array(EditableFileField::class, $field->getClassAncestry())) {
if (!empty($_FILES[$field->Name]['name'])) {
$foldername = $field->getFormField()->getFolderName();
// create the file from post data
$upload = Upload::create();
try {
$upload->loadIntoFile($_FILES[$field->Name], null, $foldername);
} catch (ValidationException $e) {
$validationResult = $e->getResult();
foreach ($validationResult->getMessages() as $message) {
$form->sessionMessage($message['message'], ValidationResult::TYPE_ERROR);
}
Controller::curr()->redirectBack();
return;
}
/** @var AssetContainer|File $file */
$file = $upload->getFile();
$file->ShowInSearch = 0;
$file->UserFormUpload = UserFormFileExtension::USER_FORM_UPLOAD_TRUE;
$file->write();
// generate image thumbnail to show in asset-admin
// you can run userforms without asset-admin, so need to ensure asset-admin is installed
if (class_exists(AssetAdmin::class)) {
AssetAdmin::singleton()->generateThumbnails($file);
}
// write file to form field
$submittedField->UploadedFileID = $file->ID;
// attach a file only if lower than 1MB
if ($file->getAbsoluteSize() < 1024 * 1024 * 1) {
$attachments[] = $file;
}
}
}
}
$submittedField->extend('onPopulationFromField', $field);
if (!$this->DisableSaveSubmissions) {
$submittedField->write();
}
$submittedFields->push($submittedField);
}
$emailData = [
'Sender' => Security::getCurrentUser(),
'HideFormData' => false,
'Fields' => $submittedFields,
'Body' => '',
];
$this->extend('updateEmailData', $emailData, $attachments);
// email users on submit.
if ($recipients = $this->FilteredEmailRecipients($data, $form)) {
foreach ($recipients as $recipient) {
$email = Email::create()
->setHTMLTemplate('email/SubmittedFormEmail')
->setPlainTemplate('email/SubmittedFormEmailPlain');
// Merge fields are used for CMS authors to reference specific form fields in email content
$mergeFields = $this->getMergeFieldsMap($emailData['Fields']);
if ($attachments && (bool) $recipient->HideFormData === false) {
foreach ($attachments as $file) {
/** @var File $file */
if ((int) $file->ID === 0) {
continue;
}
$email->addAttachmentFromData(
$file->getString(),
$file->getFilename(),
$file->getMimeType()
);
}
}
if (!$recipient->SendPlain && $recipient->emailTemplateExists()) {
$email->setHTMLTemplate($recipient->EmailTemplate);
}
// Add specific template data for the current recipient
$emailData['HideFormData'] = (bool) $recipient->HideFormData;
// Include any parsed merge field references from the CMS editor - this is already escaped
// This string substitution works for both HTML and plain text emails.
// $recipient->getEmailBodyContent() will retrieve the relevant version of the email
$emailData['Body'] = SSViewer::execute_string($recipient->getEmailBodyContent(), $mergeFields);
// Push the template data to the Email's data
foreach ($emailData as $key => $value) {
$email->addData($key, $value);
}
// check to see if they are a dynamic reply to. eg based on a email field a user selected
$emailFrom = $recipient->SendEmailFromField();
if ($emailFrom && $emailFrom->exists()) {
$submittedFormField = $submittedFields->find('Name', $recipient->SendEmailFromField()->Name);
if ($submittedFormField && $submittedFormField->Value && is_string($submittedFormField->Value)) {
$email->setReplyTo(explode(',', $submittedFormField->Value));
}
} elseif ($recipient->EmailReplyTo) {
$email->setReplyTo(explode(',', $recipient->EmailReplyTo));
}
// check for a specified from; otherwise fall back to server defaults
if ($recipient->EmailFrom) {
$email->setFrom(explode(',', $recipient->EmailFrom));
}
// check to see if they are a dynamic reciever eg based on a dropdown field a user selected
$emailTo = $recipient->SendEmailToField();
try {
if ($emailTo && $emailTo->exists()) {
$submittedFormField = $submittedFields->find('Name', $recipient->SendEmailToField()->Name);
if ($submittedFormField && is_string($submittedFormField->Value)) {
$email->setTo(explode(',', $submittedFormField->Value));
} else {
$email->setTo(explode(',', $recipient->EmailAddress));
}
} else {
$email->setTo(explode(',', $recipient->EmailAddress));
}
} catch (Swift_RfcComplianceException $e) {
// The sending address is empty and/or invalid. Log and skip sending.
$error = sprintf(
'Failed to set sender for userform submission %s: %s',
$submittedForm->ID,
$e->getMessage()
);
Injector::inst()->get(LoggerInterface::class)->notice($error);
continue;
}
// check to see if there is a dynamic subject
$emailSubject = $recipient->SendEmailSubjectField();
if ($emailSubject && $emailSubject->exists()) {
$submittedFormField = $submittedFields->find('Name', $recipient->SendEmailSubjectField()->Name);
if ($submittedFormField && trim($submittedFormField->Value)) {
$email->setSubject($submittedFormField->Value);
} else {
$email->setSubject(SSViewer::execute_string($recipient->EmailSubject, $mergeFields));
}
} else {
$email->setSubject(SSViewer::execute_string($recipient->EmailSubject, $mergeFields));
}
$this->extend('updateEmail', $email, $recipient, $emailData);
if ((bool)$recipient->SendPlain) {
// decode previously encoded html tags because the email is being sent as text/plain
$body = html_entity_decode($emailData['Body']) . "\n";
if (isset($emailData['Fields']) && !$emailData['HideFormData']) {
foreach ($emailData['Fields'] as $field) {
if ($field instanceof SubmittedFileField) {
$body .= $field->Title . ': ' . $field->ExportValue ." \n";
} else {
$body .= $field->Title . ': ' . $field->Value . " \n";
}
}
}
$email->setBody($body);
try {
$email->sendPlain();
} catch (Exception $e) {
Injector::inst()->get(LoggerInterface::class)->error($e);
}
} else {
try {
$email->send();
} catch (Exception $e) {
Injector::inst()->get(LoggerInterface::class)->error($e);
}
}
}
}
$submittedForm->extend('updateAfterProcess');
$session = $this->getRequest()->getSession();
$session->clear("FormInfo.{$form->FormName()}.errors");
$session->clear("FormInfo.{$form->FormName()}.data");
$referrer = (isset($data['Referrer'])) ? '?referrer=' . urlencode($data['Referrer']) : "";
// set a session variable from the security ID to stop people accessing
// the finished method directly.
if (!$this->DisableAuthenicatedFinishAction) {
if (isset($data['SecurityID'])) {
$session->set('FormProcessed', $data['SecurityID']);
} else {
// if the form has had tokens disabled we still need to set FormProcessed
// to allow us to get through the finshed method
if (!$this->Form()->getSecurityToken()->isEnabled()) {
$randNum = rand(1, 1000);
$randHash = md5($randNum);
$session->set('FormProcessed', $randHash);
$session->set('FormProcessedNum', $randNum);
}
}
}
if (!$this->DisableSaveSubmissions) {
$session->set('userformssubmission'. $this->ID, $submittedForm->ID);
}
return $this->redirect($this->Link('finished') . $referrer . $this->config()->get('finished_anchor'));
}
/**
* Allows the use of field values in email body.
*
* @param ArrayList $fields
* @return ArrayData
*/
protected function getMergeFieldsMap($fields = [])
{
$data = ArrayData::create([]);
foreach ($fields as $field) {
$data->setField($field->Name, DBField::create_field('Text', $field->Value));
}
return $data;
}
/**
* This action handles rendering the "finished" message, which is
* customizable by editing the ReceivedFormSubmission template.
*
* @return ViewableData
*/
public function finished()
{
$submission = $this->getRequest()->getSession()->get('userformssubmission'. $this->ID);
if ($submission) {
$submission = SubmittedForm::get()->byId($submission);
}
$referrer = isset($_GET['referrer']) ? urldecode($_GET['referrer']) : null;
if (!$this->DisableAuthenicatedFinishAction) {
$formProcessed = $this->getRequest()->getSession()->get('FormProcessed');
if (!isset($formProcessed)) {
return $this->redirect($this->Link() . $referrer);
} else {
$securityID = $this->getRequest()->getSession()->get('SecurityID');
// make sure the session matches the SecurityID and is not left over from another form
if ($formProcessed != $securityID) {
// they may have disabled tokens on the form
$securityID = md5($this->getRequest()->getSession()->get('FormProcessedNum'));
if ($formProcessed != $securityID) {
return $this->redirect($this->Link() . $referrer);
}
}
}
$this->getRequest()->getSession()->clear('FormProcessed');
}
$data = [
'Submission' => $submission,
'Link' => $referrer
];
$this->extend('updateReceivedFormSubmissionData', $data);
return $this->customise([
'Content' => $this->customise($data)->renderWith(__CLASS__ . '_ReceivedFormSubmission'),
'Form' => '',
]);
}
/**
* Outputs the required JS from the $watch input
*
* @param array $watch
*
* @return string
*/
protected function buildWatchJS($watch)
{
$result = '';
foreach ($watch as $key => $rule) {
$events = implode(' ', $rule['events']);
$selectors = implode(', ', $rule['selectors']);
$conjunction = $rule['conjunction'];
$operations = implode(" {$conjunction} ", $rule['operations']);
$target = $rule['targetFieldID'];
$holder = $rule['holder'];
$isFormStep = strpos($target, 'EditableFormStep') !== false;
$result .= <<<EOS
\n
$('.userform').on('{$events}',
"{$selectors}",
function (){
if ({$operations}) {
$('{$target}').{$rule['view']};
{$holder}.{$rule['view']}.trigger('{$rule['holder_event']}');
} else {
$('{$target}').{$rule['opposite']};
{$holder}.{$rule['opposite']}.trigger('{$rule['holder_event_opposite']}');
}
});
EOS;
if ($isFormStep) {
// Hide the step jump button if the FormStep has is initially hidden.
// This is particularly important beacause the next/prev page buttons logic is controlled by
// the visibility of the FormStep buttons
// The HTML for the FormStep buttons is defined in UserFormProgress.ss
$id = str_replace('#', '', $target);
$result .= <<<EOS
$('.step-button-wrapper[data-for="{$id}"]').addClass('hide');
EOS;
} else {
// If a field's initial state is set to be hidden, a '.hide' class will be added to the field as well
// as the fieldholder. Afterwards, JS only removes it from the fieldholder, thus the field stays hidden.
// We'll update update the JS so that the '.hide' class is removed from the field from the beginning,
// though we need to ensure we don't do this on FormSteps (page breaks) otherwise we'll mistakenly
// target fields contained within the formstep
$result .= <<<EOS
$("{$target}").find('.hide').removeClass('hide');
EOS;
}
}
return $result;
}
}