Subversion Repositories web.active

Rev

Blame | Last modification | View Log | Download

<?php namespace ProcessWire;

use \Jos\SpamProtection;
use \Jos\Mailer;

/**
*
* SimpleContactForm
*
* See README.md for usage instructions.
*
* @author Tabea David <info@justonestep.de>
* @version 1.0.7
* @copyright Copyright (c) 2017
* @see https://github.com/justonestep/processwire-simplecontactform
* @see http://www.processwire.com
*/

/**
* Class SimpleContactForm
*/
class SimpleContactForm extends WireData implements Module {

  /**
   * Get module information
   *
   * @return array
   */
  public static function getModuleInfo() {
    return array(
      'title' => 'Simple Contact Form',
      'summary' => 'Just a simple contact form.',
      'version' => 107,
      'href' => 'https://github.com/justonestep/processwire-simplecontactform',
      'singular' => true,
      'autoload' => true,
      'icon' => 'envelope',
    );
  }

  /**
   * string class name
   */
  const CLASS_NAME = 'SimpleContactForm';

  /**
   * string template name
   */
  const TEMPLATE_NAME = 'simple_contact_form';

  /**
   * string template name for save messages
   */
  const SM_TEMPLATE_NAME = 'simple_contact_form_messages';

  /**
   * string tag name
   */
  const TAG_NAME = 'scf';

  /**
   * Additional fields
   */
  static protected $additionalFields = array('date', 'ip');

  /**
   * Spam protection fields
   */
  static protected $spamFields = array('scf-date', 'scf-website', 'sumbit', 'token');

  /**
   * Markup used during the render() method
   */
  static protected $markup = array(
    'list' => "{out}\n",
    'item' => "\n\t<div {attrs}>\n{out}\n\t</div>",
    'item_label' => "\n\t\t<label class='form__item--label' for='{for}'>{out}</label>",
    'item_label_hidden' => "\n\t\t<label class='field__header field__header--hidden {class}'>{out}</label>",
    'item_content' => "{out}",
    'item_error' => "\n<p class='field--error--message'>{out}</p>",
    'item_description' => "\n<p class='field__description'>{out}</p>",
    'item_head' => "\n<h2>{out}</h2>",
    'item_notes' => "\n<p class='field__notes'>{out}</p>",
    'item_icon' => "",
    'item_toggle' => "",
    // ALSO:
    // InputfieldAnything => array( any of the properties above to override on a per-Inputifeld basis)
  );

   /**
   * Classes used during the render() method
   */
  static protected $classes = array(
    'form' => 'form  js-simplecontactform', // additional clases for inputfieldform (optional)
    'form_error' => 'form--error--message',
    'form_success' => 'form--success--message',
    'list' => 'fields',
    'list_clearfix' => 'clearfix',
    'item' => 'form__item form__item--{name}',
    'item_label' => '', // additional classes for inputfieldheader (optional)
    'item_content' => '',  // additional classes for inputfieldcontent (optional)
    'item_required' => 'field--required', // class is for inputfield
    'item_error' => 'field--error', // note: not the same as markup[item_error], class is for inputfield
    'item_collapsed' => 'field--collapsed',
    'item_column_width' => 'field__column',
    'item_column_width_first' => 'field__column--first',
    'item_show_if' => 'field--show-if',
    'item_required_if' => 'field--required-if'
    // ALSO:
    // InputfieldAnything => array( any of the properties above to override on a per-Inputifeld basis)
  );

  /**
   * construct
   */
  public function __construct() {
    // require spam lib
    require_once($this->config->paths->SimpleContactForm . 'lib/SpamProtection.php');
    require_once($this->config->paths->SimpleContactForm . 'lib/Mailer.php');

    // add log file
    $this->log = new FileLog($this->config->paths->logs . strtolower(self::CLASS_NAME) . '-log.txt');
  }

  /**
   * Initialize the module
   *
   * ProcessWire calls this when the module is loaded. For 'autoload' modules, this will be called
   * when ProcessWire's API is ready. As a result, this is a good place to attach hooks.
   */
  public function init() {
    $allFieldsExtended = $this->allFields;
    foreach (self::$additionalFields as $f) $allFieldsExtended[] = $f;
    $this->allFieldsExtended = $allFieldsExtended;
    $this->submitName = 'submit';
    $this->btnClass = 'button';
    $this->btnText = $this->_('Send');
  }

  /**
   * Initialize the module - ready
   *
   * ProcessWire calls this when the module is loaded. For 'autoload' modules, this will be called
   * when ProcessWire's API is ready. As a result, this is a good place to attach hooks.
   */
  public function ready() {
    $this->errorMessage = $this->_('Please verify the data you have entered.');
    $this->successMessage = $this->_('Your contact request has been sent successfully!');

    $this->addHookBefore('Modules::saveModuleConfigData', $this, 'createAndAddFieldsOnSaveModuleConfigData');
  }

  /**
   * Set markup
   *
   * @param Form $form
   * @param array $options
   */
  private function setMarkup(&$form, $options) {
    $markup = isset($options['markup']) ? array_merge(self::$markup, $options['markup']) : self::$markup;
    $form->setMarkup($markup);
  }

  /**
   * Set classes
   *
   * @param Form $form
   * @param array $options
   */
  private function setClasses(&$form, $options) {
    $classes = isset($options['classes']) ? array_merge(self::$classes, $options['classes']) : self::$classes;
    $form->setClasses($classes);
  }

  /**
   * Render Success Message
   *
   * @param Form $form
   * @return string
   */
  private function renderSuccessMessage($form) {
    return "<p class='{$form->getClasses()['form_success']}'>{$this->successMessage}</p>";
  }

  /**
   * Render Form
   *
   * @param array $options
   * @return string
   */
  private function renderForm($options) {
    $form = $this->getForm($options);
    $this->setMarkup($form, $options);
    $this->setClasses($form, $options);

    // on form submit
    if ($this->input->post->{$this->submitName} && !$this->input->get->success) {
      // process form input and validate fields
      $form->processInput($this->input->post);
      $this->processValidation($form);

      // anti spam measures
      if (!$form->getErrors()) {
        $spamProtector = new SpamProtection();

        // exclude markup fields from spam count
        // AND exclude checkbox fields (if unchecked) from spam count
        // AND exclude password confirmation field from spam count BUT the other way
        // AND exclude verify email BUT the other way
        // BECAUSE it's a standard browser behaviour that the value of a checkbox is only sent if the checkbox was checked
        $excludeFields = 0;
        foreach ($this->allFields as $inputfield) {
          if ($field = $this->fields->get($inputfield)) {
            if ($field->type instanceof FieldtypeFieldsetOpen || $field->type instanceof FieldtypeFieldsetTabOpen) $excludeFields++;
            if ($field->type instanceof FieldtypeCheckbox && !$this->input->post->{$field->name}) $excludeFields++;
            if ($field->type instanceof FieldtypePassword) $excludeFields--;
            if ($field->type instanceof FieldtypeEmail && $field->confirm) $excludeFields--;
          }
        }

        $spamProtector
          ->setCount(count($this->allFields) - $excludeFields + count(self::$spamFields))
          ->setTimeRange($this->antiSpamTimeMin, $this->antiSpamTimeMax)
          ->setSaveMessages($this->saveMessages)
          ->setExcludeIpAdresses($this->antiSpamExcludeIps)
          ->setNumberOfSubmitsPerDay($this->antiSpamPerDay)
          ->validate();

        // didn't pass spam test
        if ($spamProtector->isSpam()) {
          $form->error(sprintf(
            $this->_("Sorry, but your message didn't pass our %s test. Please prepare another %s."),
            $spamProtector->getAnimal(),
            $spamProtector->getFruit()
          ));
          $form->appendMarkup .= "<p class='{$form->getClasses()['form_error']}'>{$form->getErrors()[0]}</p>";
          $d = $form->get('scf-date');
          $d->attr('value', time());
        }
      } else {
          $form->appendMarkup .= "<p class='{$form->getClasses()['form_error']}'>{$this->errorMessage}</p>";
      }

      // send mail
      if (!$form->getErrors()) {
        if ($this->sendEmails) $this->sendMail();
        if ($this->saveMessages) $this->saveMessage();

        if ($this->redirectSamePage) {
          $this->session->redirect($this->page->url . '?success=1');
        } elseif ($p = $this->redirectPage) {
          $this->session->redirect($this->pages->get($p)->url);
        } else {
          $out = $this->renderSuccessMessage($form);
        }
      }
    }

    if ($this->input->get->success) $out = $this->renderSuccessMessage($form);

    return isset($out) ? $out : $form->render();
  }

  /**
   * Get form
   *
   * @param array $options
   * @return Form
   */
  private function getForm($options) {
    $form = $this->modules->get('InputfieldForm');

    $form->action = isset($options['action']) ? $options['action'] : './';
    $form->method = 'post';
    $form->attr('id+name','contact-form');
    if (isset($options['prependMarkup'])) $form->prependMarkup = $options['prependMarkup'];
    if (isset($options['appendMarkup'])) $form->appendMarkup = $options['appendMarkup'];

    // add fields
    if (is_array($this->allFields)) {
      foreach ($this->allFields as $fieldname) {
        if ($field = $this->fields->get($fieldname)) {
          $inputfield = $field->getInputfield($this->page);
          $inputfield->useLanguages = false;
          $form->append($inputfield);
        }
      }
    }

    // add honeypot (spam protection)
    $honeyField = $this->modules->get('InputfieldText');
    $honeyField->name = 'scf-website';
    $honeyField->initValue = '';
    $form->append($honeyField);

    // add hidden field to save current timestamp
    $field = $this->modules->get('InputfieldHidden');
    $field->skipLabel = Inputfield::skipLabelBlank;
    $field->attr('name+id', 'scf-date');
    $field->attr('value', time());
    $field->required = 1;
    $form->append($field);

    // add a submit button to the form
    $submit = $this->modules->get('InputfieldSubmit');
    $submit->name = $this->submitName;
    $submit->attr('class', $this->btnClass);
    $submit->attr('value', $this->btnText === 'Send' ? $this->_('Send') : $this->btnText);
    $form->append($submit);

    return $form;
  }

  /**
   * Render
   * available keys:
   *   allFields, redirectSamePage, redirectPage, submitName, actionPage, btnClass, btnText
   *   emailMessage, emailAddMessage, successMessage, errorMessage
   *   sendEmails, emailSubject, emailTo, emailServer, emailReplyTo, emailAdd, emailAddSubject, emailAddTo, emailAddReplyTo
   *   saveMessages, saveMessagesParent, saveMessagesTemplate, markup, classes, prependMarkup, appendMarkup
   *
   * @param array $options
   * @return string
   */
  public function ___render($options = array()) {
    return $this->renderInstance($options);
  }

  /**
   * Render further instance
   * available keys:
   *   allFields, redirectSamePage, redirectPage, submitName, actionPage, btnClass, btnText
   *   emailMessage, emailAddMessage, successMessage, errorMessage
   *   sendEmails, emailSubject, emailTo, emailServer, emailReplyTo, emailAdd, emailAddSubject, emailAddTo, emailAddReplyTo
   *   saveMessages, saveMessagesParent, saveMessagesTemplate, markup, classes, prependMarkup, appendMarkup
   *
   * @param array $options
   * @return string
   */
  private function renderInstance($options) {
    // overwrite module config settings
    foreach ($options as $key => $value) {
      switch ($key) {
        // === fields
        case 'allFields':
          $this->allFields = explode(',', $value);
          $allFieldsExtended = explode(',', $value);
          foreach (self::$additionalFields as $f) $allFieldsExtended[] = $f;
          $this->allFieldsExtended = $allFieldsExtended;
          break;

        case 'redirectSamePage':
          $this->redirectSamePage = $value;
          break;

        case 'redirectPage':
          $this->redirectPage = $value;
          break;

        // === names
        case 'submitName':
          $this->submitName = $value;
          break;

        case 'btnClass':
          $this->btnClass = $value;
          break;

        case 'btnText':
          $this->btnText = $value;
          break;

        // === messages
        case 'successMessage':
          $this->successMessage = $value;
          break;
        case 'errorMessage':
          $this->errorMessage = $value;
          break;
        case 'emailMessage':
          $this->emailMessage = $value;
          break;
        case 'emailAddMessage':
          $this->emailAddMessage = $value;
          break;

        // === email
        case 'sendEmails':
          $this->sendEmails = $value;
          break;
        case 'emailSubject':
          $this->emailSubject = $value;
          break;
        case 'emailTo':
          $this->emailTo = $value;
          break;
        case 'emailServer':
          $this->emailServer = $value;
          break;
        case 'emailReplyTo':
          $this->emailReplyTo = $value;
          break;
        case 'emailAdd':
          $this->emailAdd = $value;
          break;
        case 'emailAddSubject':
          $this->emailAddSubject = $value;
          break;
        case 'emailAddTo':
          $this->emailAddTo = $value;
          break;
        case 'emailAddReplyTo':
          $this->emailAddReplyTo = $value;
          break;

        // === general
        case 'saveMessages':
          $this->saveMessages = $this->boolval($value);
          break;

        case 'saveMessagesParent':
          $this->saveMessagesParent = $value;
          break;

        case 'saveMessagesTemplate':
          $this->saveMessagesTemplate = $value;
          break;
      }
    }

    // send additional email?
    if (isset($this->emailAdd) && $this->emailAdd) {
      if (!isset($this->emailAddMessage)) $this->emailAddMessage = $this->emailMessage;
      if (!isset($this->emailAddTo)) $this->emailAddTo = $this->emailTo;
      if (!isset($this->emailAddReplyTo)) $this->emailAddReplyTo = $this->emailReplyTo;
      if (!isset($this->emailAddSubject)) $this->emailAddSubject = $this->emailSubject;
    }

    // execute render
    return $this->renderForm($options);
  }

  /**
   * Get mail message content
   *
   * @param string $text
   * @return string
   */
  private function getMessageContent($text) {
    if (!empty($text)) {
      $date = new \DateTime();
      if (preg_match('/\%date\%/', $text)) $text = str_replace('%date%', $date->format('Y-m-d H:i:s'), $text);
      preg_match_all('/\%(.*?)\%/', $text, $matches);

      foreach ($matches[0] as $key => $match) {
        $text = str_replace($match, $this->sanitizer->textarea($this->input->post->{$matches[1][$key]}), $text);
      }
    } else {
      $message = array();
      foreach ($this->allFields as $inputfield) {
        $message[] = $inputfield . ': ' . $this->sanitizer->textarea($this->input->post->{$inputfield});
      }
      $date = new \DateTime();
      $message[] = 'Date: ' . $date->format('Y-m-d H:i:s');
      $text = implode("\r\n", $message);
    }

    return $text;
  }

  /**
   * Send Mail
   */
  public function ___sendMail() {
    $mail = new Mailer(
      $this->emailTo,
      $this->emailServer,
      $this->emailReplyTo,
      $this->emailSubject,
      trim($this->getMessageContent($this->emailMessage))
    );

    $numSent = $mail->send();

    // send additional mail
    if ($this->sendEmails && isset($this->emailAdd) && $this->emailAdd) $this->sendAdditionalMail();

    // log whether a mail has been sent or not
    if ($numSent) {
      $logmessage = array(
        $_SERVER['HTTP_USER_AGENT'],
        $_SERVER['REMOTE_ADDR'],
        $this->emailTo
      );

      $this->log->save('[SUCCESS] ' . implode(', ', $logmessage));
    } else {
      // mail has not been sent
      $this->log->save("[ERROR] Mail has not been sent to {$this->emailTo}");
    }
  }

  /**
   * Save Message
   */
  public function ___saveMessage() {
    $date = new \DateTime();

    $name = array($date->getTimestamp());
    if ($parts = $this->saveMessagesScheme) {
      foreach ($parts as $part) {
        $name[] = $this->input->post->$part;
      }
    }
    $pageName = implode(' ', $name);

    $p = new Page();
    $p->template = $this->saveMessagesTemplate;
    $p->parent = wire('pages')->get($this->saveMessagesParent);
    $p->name = $this->sanitizer->pageName($pageName, true);
    $p->title = $pageName;
    $p->save(); // IMPORTANT: Save the page once, so that file-type fields can be added to it below!

    foreach ($this->allFields as $in) $p->$in = $this->input->post->$in;
    $p->scf_date = $date->getTimestamp();
    $p->scf_ip = $_SERVER['REMOTE_ADDR'];
    $p->save();
  }

  /**
   * Send Additional Mail
   */
  public function ___sendAdditionalMail() {
    $mail = new Mailer(
      $this->emailAddTo,
      $this->emailServer,
      $this->emailAddReplyTo,
      $this->emailAddSubject,
      trim($this->getMessageContent($this->emailAddMessage))
    );

    if ($mail->send()) {
      $logmessage = array(
        $_SERVER['HTTP_USER_AGENT'],
        $_SERVER['REMOTE_ADDR'],
        $this->emailAddTo
      );

      $this->log->save('[SUCCESS] ' . implode(', ', $logmessage));
    } else {
      $this->log->save("[ERROR] Additional mail could not be sent to {$this->emailAddTo}");
    }
  }

  /**
   * Hook create and add template fields
   *
   * @param HookEvent $event
   */
  public function createAndAddFieldsOnSaveModuleConfigData(HookEvent $event) {
    if ($event->arguments[0] === self::CLASS_NAME) {
      $configData = $event->arguments[1];

      // saveMessages enabled? create template if it doesn't exist
      $fg = $configData['saveMessages'] ? $this->createSaveMessagesTemplate($configData) : null;

      // get fields
      if ($configData['addFields']) {
        $this->addNewFields($configData, $fg);
        $event->setArgument(1, $configData);
      }

      // cleanup, update from 0.x to 1.x @todo: deprecated
      $this->upgradeItems($fg, $configData);
    }
  }

  /**
   * Add new fields
   *
   * @param array $configData
   * @param Fieldgroup $fg
   */
  protected function addNewFields(&$configData, $fg) {
    $newFields = $configData['addFields'];
    $allFields = $configData['allFields'];

    foreach (explode(',', preg_replace('/\s/', '', $newFields)) as $name) {
      if (is_null($this->fields->get("scf_$name"))) {
        $f = new Field();
        $f->type = $this->modules->get('FieldtypeText');
        $f->name = "scf_$name";
        $f->label = 'SCF - ' . ucfirst($name);
        $f->tags = self::TAG_NAME;
        $f->columnWidth = '25';
        $f->save();

        // saveMessages enabled - save fields to template
        if ($fg) {
          $fg->add($f); // add field to fieldgroup
          $fg->save(); // save fieldgroup
        }
      }

      if (!in_array("scf_$name", $allFields)) $allFields[] = "scf_$name";
    }

    $configData['allFields'] = $allFields;
    $configData['addFields'] = '';
  }

  /**
   * Create save messages template
   *
   * @param array $configData
   * @return Fieldgroup
   */
  protected function createSaveMessagesTemplate($configData) {
    if ($template = $this->templates->get(self::SM_TEMPLATE_NAME)) {
      $fg = $template->fieldgroup; // get existing fieldgroup
    } else {
      // new fieldgroup
      $fg = new Fieldgroup();
      $fg->name = self::SM_TEMPLATE_NAME;
      $fg->add($this->fields->get('title')); // needed title field
      $fg->save();

      // new template
      $template = new Template();
      $template->name = self::SM_TEMPLATE_NAME;
      $template->fieldgroup = $fg; // add the fieldgroup
      $template->slashUrls = 1;
      $template->noPrependTemplateFile = 1;
      $template->noAppendTemplateFile = 1;
      $template->tags = self::TAG_NAME;
      $template->save();
    }

    // scf_spamip (check)
    if (!$fg->scf_spamip) {
      if (!$field = $this->fields->get('scf_spamIp')) {
        $field = new Field();
        $field->type = $this->modules->get('FieldtypeCheckbox');
        $field->name = 'scf_spamIp';
        $field->value = 1;
      }

      $field->label = __('Add IP to spam list');
      $field->description = __('If you activate this checkbox, further contact requests from this ip address will be treated as spam.');
      $field->columnWidth = 25;
      $field->save();

      $fg->add($field);
      $fg->save();
    }

    // scf_date (datetime)
    if (!$fg->scf_date) {
      if (!$field = $this->fields->get('scf_date')) {
        $field = new Field();
        $field->name = 'scf_date';
      }

      $field->type = $this->modules->get('FieldtypeDatetime');
      $field->label = __('Creation date');
      $field->datepicker = InputfieldDatetime::datepickerClick;
      $field->dateInputFormat = 'Y/m/d';
      $field->timeInputFormat = 'H:i';
      $field->dateOutputFormat = 'Y/m/d';
      $field->timeOutputFormat = 'H:i';
      $field->columnWidth = 25;
      $field->save();

      $fg->add($field);
      $fg->save();
    }

    // scf_ip (text)
    if (!$fg->scf_ip) {
      if (!$field = $this->fields->get('scf_ip')) {
        $field = new Field();
        $field->type = $this->modules->get('FieldtypeText');
        $field->name = 'scf_ip';
      }

      $field->label = __('IP address');
      $field->columnWidth = 25;
      $field->save();

      $fg->add($field);
      $fg->save();
    }

    $this->validateParent($configData);
    return $fg;
  }

  /**
   * Validate parent page
   *
   * @param array $configData
   */
  protected function validateParent($configData) {
    // add default for save messages parent
    if (!$configData['saveMessagesParent']) $configData['saveMessagesParent'] = $this->config->rootPageID;

    // check whether selected parent allows children (noChildren must be 0)
    if ($this->pages->get($configData['saveMessagesParent'])->template->noChildren > 0) {
      $this->log->error($this->_('Please choose another parent or change the belonging template. It must allow children.'));
    }
  }

  /**
   * Validate parent page
   *
   * @param Fieldgroup $fg
   * @param array $configData
   */
  protected function upgradeItems($fg, $configData) {
    if ($this->fields->get('repeater_scfmessages') && $configData['saveMessages']) {
      // first: add fields to template
      foreach ($this->allFields as $f) {
        $fg->add($f); // add field to fieldgroup
        $fg->save(); // save fieldgroup
      }

      // second: repater items to pages
      foreach ($this->pages->get('template=' . self::SM_TEMPLATE_NAME)->repeater_scfmessages as $item) {
        if (!$item->scf_date) continue;

        $name = array($item->created);
        if ($parts = $configData['saveMessagesScheme']) {
          foreach ($parts as $part) {
            $name[] = $item->$part;
          }
        }
        $pageName = implode(' ', $name);

        if ($this->pages->find('name=' . $this->sanitizer->pageName($pageName, true))->count() > 0) continue;

        $p = new Page();
        $p->template = self::SM_TEMPLATE_NAME;
        $p->parent = wire('pages')->get($configData['saveMessagesParent']);
        $p->name = $this->sanitizer->pageName($pageName, true);
        $p->title = $pageName;
        $p->save(); // IMPORTANT: Save the page once, so that file-type fields can be added to it below!

        $date = new \DateTime();
        $date->setTimestamp($item->created);
        $p->scf_date = $date->format('Y/m/d H:i');
        $p->scf_ip = $item->scf_ip;
        foreach ($this->allFields as $f) if ($item->$f) $p->$f = $item->$f;
        $p->save();
      }

      // third: delete repeater and template simple_contact_form
      $fg->remove($this->fields->get('repeater_scfmessages'));
      $fg->save();
      $this->fields->delete($this->fields->get('repeater_scfmessages'));
      $this->templates->delete($this->templates->get(self::TEMPLATE_NAME));
    }
  }

  /**
   * Get the boolean value of a variable
   *
   * @param $val
   */
  public function boolval($val) {
    if (!function_exists('boolval')) {
      // (PHP 5 < 5.5.0)
      $bool = (bool) $val;
    } else {
      // (PHP 5 >= 5.5.0)
      $bool = boolval($val);
    }

    return $bool;
  }

  /**
   * Hookable method called after the form was processed
   * Allows custom/extra validation and field manipulation
   */
  protected function ___processValidation($form) {}

}