[TASK] update and formatting

This commit is contained in:
TLRZ Seyfferth
2020-01-28 10:41:16 +01:00
parent 9c121fbcc5
commit 571576756e
41 changed files with 7430 additions and 21 deletions
+476
View File
@@ -0,0 +1,476 @@
<?php
/**
* p01-contact - A simple contact forms manager
*
* @package p01contact
* @link https://github.com/nliautaud/p01contact
* @author Nicolas Liautaud
*/
namespace P01C;
require_once 'P01contact_Session.php';
require_once 'P01contact_Form.php';
require_once 'vendor/spyc.php';
class P01contact
{
public $version;
public $default_lang;
private $config;
private $langs;
public function __construct()
{
define('P01C\VERSION', '1.1.6');
$this->version = VERSION;
define('P01C\SERVERNAME', $_SERVER['SERVER_NAME']);
define('P01C\SERVERPORT', $_SERVER['SERVER_PORT']);
define('P01C\SCRIPTNAME', $_SERVER['SCRIPT_NAME']);
define('P01C\SCRIPTPATH', get_included_files()[0]);
define('P01C\HTTPS', !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
define('P01C\PORT', SERVERPORT && SERVERPORT != 80 && SERVERPORT != 443 ? ':'.SERVERPORT : '');
define('P01C\PROTOCOL', HTTPS || SERVERPORT == 443 ? 'https' : 'http');
define('P01C\SERVER', PROTOCOL . '://' . SERVERNAME . PORT);
define('P01C\PAGEURI', $_SERVER['REQUEST_URI']);
define('P01C\PAGEURL', SERVER . PAGEURI);
define('P01C\PATH', dirname(__DIR__) . '/');
define('P01C\ROOT', str_replace(SCRIPTNAME,'', SCRIPTPATH));
define('P01C\RELPATH', str_replace(ROOT, '', PATH));
define('P01C\LANGSPATH', PATH . 'lang/');
define('P01C\TPLPATH', PATH . 'src/templates/');
define('P01C\CONFIGPATH', PATH . 'config.json');
define('P01C\LOGPATH', PATH . 'log.json');
define('P01C\REPOURL', 'https://github.com/nliautaud/p01contact');
define('P01C\WIKIURL', 'https://github.com/nliautaud/p01contact/wiki');
define('P01C\ISSUESURL', 'https://github.com/nliautaud/p01contact/issues');
define('P01C\APILATEST', 'https://api.github.com/repos/nliautaud/p01contact/releases/latest');
$this->loadConfig();
$this->loadLangs();
if ($this->config('debug')) {
$this->enablePHPdebug();
}
Session::stack('pageloads', time());
}
/**
* Query the releases API and return the new release infos, if there is one.
*
* @see https://developer.github.com/v3/repos/releases/#get-the-latest-release
* @return object the release infos
*/
public function getNewRelease()
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, APILATEST);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 1);
curl_setopt($ch, CURLOPT_USERAGENT, 'p01contact/curl');
$resp = curl_exec($ch);
curl_close($ch);
if ($resp) {
return json_decode($resp);
}
return;
}
/**
* Parse a string to replace tags by forms
*
* Find tags, create forms structures, check POST and modify string.
* @param string $contents the string to parse
* @return string the modified string
*/
public function parse($contents)
{
$sp = '(?:\s|</?p>)*';
$pattern = "`(?<!<code>)\(%\s*contact\s*(\w*)\s*:?$sp(.*?)$sp%\)`s";
preg_match_all($pattern, $contents, $tags, PREG_SET_ORDER);
foreach ($tags as $tag) {
$form = $this->newForm($tag[2], $tag[1]);
$contents = preg_replace($pattern, $form, $contents, 1);
}
return $contents;
}
/**
* Return a form based on the given parameters and lang
*
* @param string $params the parameters string, according to the syntax
* @param string $lang form-specific language code
* @return string the html form
*/
public function newForm($params = '', $lang = null)
{
$defaultStyle = '';
static $once;
if (!$once) {
$defaultStyle = '<link rel="stylesheet" href="'.SERVER.RELPATH.'style.css"/>';
$once = true;
}
$form = new P01contactForm($this);
$form->parseTag($params);
if ($lang) {
$form->lang = $lang;
}
$form->post();
return $defaultStyle . $form->html();
}
/**
* Display system and P01contact infos.
*
* @return string the html report
*/
public function debugReport()
{
$out = '<h2 style="color:#c33">p01-contact debug</h2>';
$out.= '<h3>Health :</h3>';
$health = 'PHP version : '.phpversion()."\n";
$health.= 'PHP mbstring (UTF-8) : '.(extension_loaded('mbstring') ? 'OK' : 'MISSING');
$out.= preint($health, true);
$out.= '<h3>Constants :</h3>';
$constants = get_defined_constants(true)['user'];
$filteredConstants = array_filter(array_keys($constants), function ($key) {
return 0 === strpos($key, __namespace__);
});
$filteredConstants = array_intersect_key($constants, array_flip($filteredConstants));
$out .= preint($filteredConstants, true);
$out .= Session::report();
if (!empty($_POST)) {
$out.= '<h3>$_POST :</h3>';
$out.= preint($_POST, true);
}
$out.= '<h3>$p01contact :</h3>';
$out.= preint($this, true);
return $out;
}
/**
* Enable PHP error reporting
*/
public function enablePHPdebug()
{
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
}
/*
* LANG
*/
/**
* Load language files
*/
private function loadLangs()
{
$this->langs = [];
$files = glob(LANGSPATH . '*.yml');
foreach ($files as $f) {
$parsed = \Spyc::YAMLLoad($f);
if (!$parsed || !isset($parsed['key'])) {
continue;
}
$this->langs[$parsed['key']] = $parsed;
}
}
/**
* Return a traduction of the keyword
*
* Manage languages between requested langs and existing traductions.
* @param string $key the keyword
* @return string
*/
public function lang($key, $lang = null)
{
$default = !empty($this->default_lang) ? $this->default_lang : 'en';
if (!$lang) {
$lang = $this->config('lang');
}
if (empty($lang)
|| !isset($this->langs[$lang])
|| !isset($this->langs[$lang]['strings'][$key])) {
$lang = $default;
}
$strings = $this->langs[$lang]['strings'];
if (!empty($strings[$key])) {
return trim($strings[$key]);
}
return ucfirst($key);
}
/**
* Return the languages objects
* @return array
*/
public function langs()
{
return $this->langs;
}
/*
* CONFIG
*/
/**
* Load the JSON configuration file.
*/
private function loadConfig()
{
$content = file_exists(CONFIGPATH) ? file_get_contents(CONFIGPATH) : null;
$this->config = $content ? json_decode($content) : (object) array();
$this->setDefaultConfig();
}
/**
* Set the obligatory settings if missing.
*/
private function setDefaultConfig()
{
$default = array(
'default_params' => 'name!, email!, subject!, message!',
'separator' => ',',
'logs_count' => 10,
'use_honeypot' => true,
'min_sec_after_load' => '3',
'max_posts_by_hour' => '10',
'min_sec_between_posts' => '5',
);
foreach ($default as $key => $value) {
if (empty($this->config->{$key})) {
$this->config->{$key} = $value;
}
}
}
/**
* Add an entry to the logs.
*/
public function log($data)
{
if (!$this->config('logs_count')) {
return;
}
$logs = json_decode(@file_get_contents(LOGPATH));
$logs[] = $data;
$max = max(0, intval($this->config('logs_count')));
while (count($logs) > $max) {
array_shift($logs);
}
$this->updateJSON(LOGPATH, $logs);
}
/**
* Update a JSON file with new data.
*
* @param string $file_path the config file path
* @param array $new_values the new values to write
* @param array $old_values the values to change
* @return boolean file edition sucess
*/
private function updateJSON($path, $new_values)
{
if ($file = fopen($path, 'w')) {
fwrite($file, json_encode($new_values, JSON_PRETTY_PRINT));
fclose($file);
return true;
} return false;
}
/**
* Return a setting value from the config.
* @param mixed $key the setting key, or an array as path to sub-key
* @return mixed the setting value
*/
public function config($key)
{
if (!is_array($key)) {
$key = array($key);
}
$curr = $this->config;
foreach ($key as $k) {
if (is_numeric($k)) {
$k = intval($k);
if (!isset($curr[$k])) {
return;
}
$curr = $curr[$k];
} else {
if (!isset($curr->$k)) {
return;
}
$curr = $curr->$k;
}
$k = $curr;
}
return $k;
}
/*
* TEMPLATES
*/
/**
* Return a template file content
*/
public function getTemplate($name)
{
static $cache;
if (isset($cache[$name])) {
return $cache[$name];
}
if (!isset($cache)) {
$cache = array();
}
$cache[$name] = @file_get_contents(TPLPATH . $name . '.html');
return $cache[$name];
}
/**
* Set the obligatory settings if missing.
*/
public function renderTemplate($name, $data)
{
$html = $this->getTemplate($name);
// config
$html = preg_replace_callback('`config\((.+)\)`', function ($matches) {
return $this->config(explode(',', $matches[1]));
}, $html);
// lang
$html = preg_replace_callback('`{{lang\.(\w+)}}`', function ($matches) {
return $this->lang($matches[1]);
}, $html);
// constants
$html = preg_replace_callback('`{{([A-Z]{3,})}}`', function ($matches) {
return constant(__namespace__.'\\'.$matches[1]);
}, $html);
// data
$html = preg_replace_callback('`{{(\w+)}}`', function ($matches) use ($data) {
return @$data->{$matches[1]};
}, $html);
return $html;
}
/*
* PANEL
*/
/**
* Save settings if necessary and display configuration panel content
* Parse and replace values in php config file by POST values.
*/
public function panel()
{
if (isset($_POST['p01-contact']['settings'])) {
$success = $this->updateJSON(CONFIGPATH, $_POST['p01-contact']['settings']);
$this->loadConfig();
if ($success) {
$msg = '<div class="updated">' . $this->lang('config_updated') . '</div>';
} else {
$msg = '<div class="error">'.$this->lang('config_error_modify');
$msg.= '<pre>'.CONFIGPATH.'</pre></div>';
}
return $msg . $this->panelContent();
}
return $this->panelContent();
}
/**
* Return configuration panel content, replacing the following in the template :
*
* - lang(key) : language string
* - config(key,...) : value of a config setting
* - other(key) : other value pre-defined
* - VALUE : constant value
*
* @return string
*/
private function panelContent($system = 'gs')
{
$debug = $this->config('debug');
$tpl_data = (object) null;
$tpl_data->disablechecked = $this->config('disable') ? 'checked="checked" ' : '';
$tpl_data->debugchecked = $debug ? 'checked="checked" ' : '';
$tpl_data->honeypotchecked = $this->config('use_honeypot') ? 'checked="checked" ' : '';
$tpl_data->default_lang = $this->default_lang;
$tpl_data->version = $this->version;
$list = $this->config('checklist');
if ($list) {
foreach ($list as $i => $cl) {
$bl = 'cl'.$i.'bl';
$wl = 'cl'.$i.'wl';
$tpl_data->$bl = isset($cl->type) && $cl->type == 'whitelist' ? '' : 'checked';
$tpl_data->$wl = $tpl_data->$bl ? '' : 'checked';
}
}
$lang = $this->config('lang');
$tpl_data->langsoptions = '<option value=""'.($lang==''?' selected="selected" ':'').'>Default</option>';
foreach ($this->langs() as $language) {
$tpl_data->langsoptions .= '<option value="' . $language['key'] . '" ';
if ($lang == $language['key']) {
$tpl_data->langsoptions .= 'selected="selected" ';
}
$tpl_data->langsoptions .= '>' . $language['english_name'] . '</option>';
}
$html = $this->renderTemplate($system.'_settings', $tpl_data);
//new release
$infos = '';
if ($response = $this->getNewRelease()) {
if ($debug && isset($response->message)) {
$infos .= '<div class="updated">New release check error debug : Github ';
$infos .= $response->message . '</div>';
}
if (isset($response->url) && version_compare($response->tag_name, $this->version) > 0) {
$infos .= '<div class="updated">' . $this->lang('new_release');
$infos .= '<br /><a href="' . $response->html_url . '">';
$infos .= $this->lang('download') . ' (' . $response->tag_name . ')</a></div>';
}
}
$logsblock = $this->logsTable();
return $infos . $html . $logsblock;
}
private function logsTable()
{
$logs = json_decode(@file_get_contents(LOGPATH));
if (!$logs) {
return;
}
$html = '';
foreach (array_reverse($logs) as $log) {
$html .= '<tr><td>';
$html .= implode('</td><td>', array_map('htmlentities', $log));
$html .= '</td></tr>';
}
return '<div class="logs"><h2>Logs</h2><table>'.$html.'</table></div>';
}
}
@@ -0,0 +1,406 @@
<?php
/**
* p01-contact - A simple contact forms manager
*
* @link https://github.com/nliautaud/p01contact
* @author Nicolas Liautaud
* @package p01contact
*/
namespace P01C;
class P01contactField
{
private $form;
public $id;
public $type;
public $title;
public $description;
public $value;
public $selected_values;
public $placeholder;
public $required;
public $locked;
public $error;
/**
* @param Form $form the container form
* @param int $id the field id
* @param string $type the field type
*/
public function __construct($form, $id, $type)
{
$this->form = $form;
$this->id = $id;
$this->type = $type;
}
/**
* Set the field value or selected value
*
* @param mixed $new_value the value, or an array of selected values ids
*/
public function setValue($new_value)
{
// simple value
if (!is_array($this->value)) {
$this->value = htmlentities($new_value, ENT_COMPAT, 'UTF-8', false);
return;
}
// multiples-values (checkbox, radio, select)
if (!is_array($new_value)) {
$new_value = array($new_value);
}
foreach ($new_value as $i) {
$this->selected_values[intval($i)] = true;
}
}
/**
* Reset the selected values by finding ones who starts or end with ":"
*/
public function resetSelectedValues()
{
$this->selected_values = array();
foreach ($this->value as $i => $val) {
$value = preg_replace('`(^\s*:|:\s*$)`', '', $val, -1, $count);
if ($count) {
$this->value[$i] = $value;
$this->selected_values[$i] = true;
}
}
}
/**
* Check field value.
* @return boolean
*/
public function validate()
{
// empty and required
if (empty($this->value) && $this->required) {
$this->error = 'field_required';
return false;
}
// value blacklisted or not in whitelist
if ($reason = $this->isBlacklisted()) {
$this->error = 'field_' . $reason;
return false;
}
// not empty but not valid
if (!empty($this->value) && !$this->isValid()) {
$this->error = 'field_' . $this->type;
return false;
}
return true;
}
/**
* Check if field value is valid
* Mean different things depending on field type
* @return boolean
*/
public function isValid()
{
switch ($this->type) {
case 'email':
return filter_var($this->value, FILTER_VALIDATE_EMAIL);
case 'tel':
$pattern = '`^\+?[-0-9(). ]{6,}$$`i';
return preg_match($pattern, $this->value);
case 'url':
return filter_var($this->value, FILTER_VALIDATE_URL);
case 'message':
return strlen($this->value) > $this->form->config('message_len');
case 'captcha':
return $this->reCaptchaValidity($_POST['g-recaptcha-response']);
case 'password':
return $this->value == $this->required;
default:
return true;
}
}
/**
* Check if reCaptcha is valid
* @return boolean
*/
public function reCaptchaValidity($answer)
{
if (!$answer) {
return false;
}
$params = [
'secret' => $this->form->config('recaptcha_secret_key'),
'response' => $answer
];
$url = "https://www.google.com/recaptcha/api/siteverify?" . http_build_query($params);
if (function_exists('curl_version')) {
$curl = curl_init($url);
curl_setopt($curl, CURLOPT_HEADER, false);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_TIMEOUT, 1);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($curl);
} else {
$response = file_get_contents($url);
}
if (empty($response) || is_null($response)) {
return false;
}
return json_decode($response)->success;
}
/**
* Check if field value is blacklisted
*
* Search for every comma-separated entry of every checklist
* in value, and define if it should or should not be there.
*
* @return boolean
*/
public function isBlacklisted()
{
$list = $this->form->config('checklist');
if (!$list) {
return;
}
foreach ($list as $cl) {
if ($cl->name != $this->type) {
continue;
}
$content = array_filter(explode(',', $cl->content));
foreach ($content as $avoid) {
$found = preg_match("`$avoid`", $this->value);
$foundBlacklisted = $found && $cl->type == 'blacklist';
$notFoundWhitelisted = !$found && $cl->type == 'whitelist';
if ($foundBlacklisted || $notFoundWhitelisted) {
return $cl->type;
}
}
}
return false;
}
/*
* Return the html display of the field
*
* Manage field title, error message, and type-based display
* @return string the <div>
*/
public function html()
{
$id = 'p01-contact' . $this->form->getId() . '_field' . $this->id;
$name = 'p01-contact_fields[' . $this->id . ']';
$type = $this->getGeneralType();
$orig = $type != $this->type ? $this->type : '';
$value = $this->value;
$disabled = $this->locked ? ' disabled="disabled"' : '';
$required = $this->required ? ' required ' : '';
$placeholder = $this->placeholder ? ' placeholder="'.$this->placeholder.'"' : '';
$is_single_option = is_array($this->value) && count($this->value) == 1;
if ($is_single_option) {
$html = "<div class=\"field inline $type $orig $required\">";
} else {
$html = "<div class=\"field $type $orig $required\">";
$html .= $this->htmlLabel($id);
}
switch ($type) {
case 'textarea':
$html .= '<textarea id="' . $id . '" rows="10" ';
$html .= 'name="' . $name . '"' . $disabled.$required.$placeholder;
$html .= '>' . $value . '</textarea>';
break;
case 'captcha':
$key = $this->form->config('recaptcha_public_key');
if (!$key) {
break;
}
if ($this->form->getId() == 1) {
$html .= '<script src="https://www.google.com/recaptcha/api.js"></script>';
}
$html .='<div class="g-recaptcha" id="'.$id.'" data-sitekey="'.$key.'"></div>';
$html .="<input type=\"hidden\" id=\"$id\" name=\"$name\" value=\"trigger\">";
break;
case 'checkbox':
case 'radio':
$html .= '<div class="options">';
foreach ($this->value as $i => $v) {
$selected = $this->isSelected($i) ? ' checked' : '';
$v = !empty($v) ? $v : 'Default';
$html .= '<label class="option">';
$html .= "<input id=\"{$id}_option{$i}\"";
$html .= " type=\"$type\" class=\"$type\" name=\"{$name}\"";
$html .= " value=\"$i\"$disabled$required$selected />$v";
$html .= '</label>';
}
$html .= '</div>';
break;
case 'select':
$html .= "<select id=\"$id\" name=\"$name\"$disabled$required>";
foreach ($this->value as $i => $v) {
$value = !empty($v) ? $v : 'Default';
$selected = $this->isSelected($i) ? ' selected="selected"' : '';
$html .= "<option id=\"{$id}_option{$i}\" value=\"$i\"$selected>";
$html .= $value . '</option>';
}
$html.= '</select>';
break;
default:
$html .= '<input id="' . $id . '" ';
$html .= 'name="' . $name . '" type="'.$type.'" ';
$html .= 'value="' . $value . '"' . $disabled.$required.$placeholder . ' />';
break;
}
$html .= '</div>';
return $html;
}
/*
* Return a html presentation of the field value.
*/
public function htmlMail()
{
$gen_type = $this->getGeneralType();
$properties = array();
$html = '<table style="width: 100%; margin: 1em 0; border-collapse: collapse;">';
// name
$emphasis = $this->value ? 'font-weight:bold' : 'font-style:italic';
$html .= "\n\n\n";
$html .= '<tr style="background-color: #eeeeee">';
$html .= '<td style="padding: .5em .75em"><span style="'.$emphasis.'">';
$html .= $this->title ? $this->title : ucfirst($this->form->lang($this->type));
$html .= '</span></td>';
$html .= "\t\t";
// properties
$html .= '<td style="padding:.5em 1em; text-transform:lowercase; text-align:right; font-size:.875em; color:#888888; vertical-align: middle"><em>';
if (!$this->value) {
$html .= $this->form->lang('empty') . ' ';
}
if ($this->title) {
$properties[] = $this->type;
}
if ($gen_type != $this->type) {
$properties[] = $gen_type;
}
foreach (array('locked', 'required') as $property) {
if ($this->$property) {
$properties[] = $this->form->lang($property);
}
}
if (count($properties)) {
$html .= '(' . implode(', ', $properties) . ') ';
}
$html .= '#' . $this->id;
$html .= '</em></td></tr>';
$html .= "\n\n";
// value
if (!$this->value) {
return $html . '</table>';
}
$html .= '<tr><td colspan=2 style="padding:0">';
$html .= '<div style="padding:.5em 1.5em;border:1px solid #ccc">';
switch ($gen_type) {
case 'checkbox':
case 'radio':
case 'select':
foreach ($this->value as $i => $v) {
if ($this->isSelected($i)) {
$html .= '<div>';
$checkmark = '&#9745;';
} else {
$html .= '<div style="color:#ccc; font-style:italic">';
$checkmark = '&#9744;';
}
$html .= '<span style="font-size:1.5em; vertical-align:middle; margin-right:.5em; font-style:normal">'.$checkmark.'</span>';
$html .= empty($v) ? 'Default' : $v;
$html .= "</div>\n";
}
break;
default:
$address = '~[[:alpha:]]+://[^<>[:space:]]+[[:alnum:]/]~';
$val = nl2br(preg_replace($address, '<a href="\\0">\\0</a>', $this->value));
$html .= "<p style=\"margin:0\">$val</p>";
break;
}
$html .= '</div></td></tr></table>';
return $html;
}
private function isSelected($i)
{
return is_int($i) && is_array($this->selected_values) && isset($this->selected_values[$i]);
}
/*
* Return the label of the field
* @param string $for id of the target field
* @return string the <div> (unclosed for captcha)
*/
private function htmlLabel($for)
{
$html = '<label for="' . $for . '">';
if ($this->title) {
$html .= $this->title;
} else {
$html .= ucfirst($this->form->lang($this->type));
}
if ($this->description) {
$html .= ' <em class="description">' . $this->description . '</em>';
}
if ($this->error) {
$html .= ' <span class="error-msg">' . $this->form->lang($this->error) . '</span>';
}
$html .= '</label>';
return $html;
}
/**
* Return the general type of a field, even of specials fields.
*/
private function getGeneralType()
{
$types = array(
'name' => 'text',
'subject' => 'text',
'message' => 'textarea',
'askcopy' => 'checkbox'
);
if (isset($types[$this->type])) {
return $types[$this->type];
}
return $this->type;
}
}
function preint($arr, $return = false)
{
$out = '<pre class="test" style="white-space:pre-wrap;">' . print_r(@$arr, true) . '</pre>';
if ($return) {
return $out;
}
echo $out;
}
function predump($arr)
{
echo'<pre class="test" style="white-space:pre-wrap;">';
var_dump($arr);
echo'</pre>';
}
function unset_r($a, $i)
{
foreach ($a as $k => $v) {
if (isset($v[$i])) {
unset($a[$k][$i]);
}
}
return $a;
}
+545
View File
@@ -0,0 +1,545 @@
<?php
/**
* p01-contact - A simple contact forms manager
*
* @link https://github.com/nliautaud/p01contact
* @author Nicolas Liautaud
* @package p01contact
*/
namespace P01C;
require 'P01contact_Field.php';
class P01contactForm
{
private $manager;
private $id;
private $status;
private $targets;
private $fields;
public $lang;
public $sent;
/**
* @param P01contact $P01contact
* @param int $id the form id
*/
public function __construct($P01contact)
{
static $id;
$id++;
$this->manager = $P01contact;
$this->id = $id;
$this->status = '';
$this->targets = array();
$this->fields = array();
}
/**
* Find tag parameters, populate fields and targets.
*
* @param string $params the params
*/
public function parseTag($params)
{
// assure formating
$params = str_replace('&nbsp;', ' ', $params);
$params = strip_tags(str_replace("\n", '', $params));
$params = html_entity_decode($params, ENT_QUOTES, 'UTF-8');
// explode
$sep = $this->config('separator');
$params = array_filter(explode($sep, $params));
// emails
foreach ($params as $id => $param) {
$param = trim($param);
if (filter_var($param, FILTER_VALIDATE_EMAIL)) {
$this->addTarget($param);
unset($params[$id]);
}
}
// default params
if (empty($params)) {
$default = $this->config('default_params');
$params = array_filter(explode($sep, $default));
}
// create fields
foreach (array_values($params) as $id => $param) {
$this->parseParam($id, trim($param));
}
// default email addresses
$default_emails = $this->getValidEmails($this->config('default_email'));
foreach ($default_emails as $email) {
$this->addTarget($email);
}
}
/**
* Create a field by parsing a tag parameter
*
* Find emails and parameters, create and setup form object.
* @param int $id the field id
* @param string $param the param to parse
*/
private function parseParam($id, $param)
{
$param_pattern = '`\s*([^ ,"=!]+)'; // type
$param_pattern.= '\s*(!)?'; // required!
$param_pattern.= '\s*(?:"([^"]*)")?'; // "title"
$param_pattern.= '\s*(?:\(([^"]*)\))?'; // (description)
$param_pattern.= '\s*(?:(=[><]?)?'; // =value, =>locked, =<placeholder
$param_pattern.= '\s*(.*))?\s*`'; // value
preg_match($param_pattern, $param, $param);
list(, $type, $required, $title, $desc, $assign, $values) = $param;
$field = new P01contactField($this, $id, $type);
// values
switch ($type) {
case 'select':
case 'radio':
case 'checkbox':
$field->value = explode('|', $values);
$field->resetSelectedValues();
break;
case 'askcopy':
// checkbox-like structure
$field->value = array($this->lang('askcopy'));
break;
case 'password':
// password value is required value
$field->required = $values;
break;
default:
if ($assign == '=<') {
$field->placeholder = $values;
} else {
// simple value
$field->value = $values;
}
}
// required
if ($type != 'password') {
$field->required = $required == '!';
}
if ($type == 'captcha') {
$field->required = true;
}
$field->title = $title;
$field->description = $desc;
$field->locked = $assign == '=>';
$this->addField($field);
}
/**
* Update POSTed form and try to send mail
*
* Check posted data, update form data,
* define fields errors and form status.
* At least, if there is no errors, try to send mail.
*/
public function post()
{
if (empty($_POST['p01-contact_form'])
|| $_POST['p01-contact_form']['id'] != $this->id ) {
return;
}
// check token
if (!$this->checkToken()) {
$this->setStatus('sent_already');
$this->setToken();
$this->reset();
return;
}
$posted = $_POST['p01-contact_fields'];
// populate fields values and check errors
$hasFieldsErrors = false;
$fields = $this->getFields();
foreach ($fields as $field) {
if (!isset($posted[$field->id])) {
continue;
}
$posted_val = $posted[$field->id];
$field->setValue($posted_val);
$hasFieldsErrors = !$field->validate() || $hasFieldsErrors;
}
// check errors and set status
if ($this->config('disable')) {
$this->setStatus('disable');
return;
}
if (count($this->targets) == 0) {
$this->setStatus('error_notarget');
return;
}
if ($hasFieldsErrors || $this->checkSpam($posted) !== true) {
return;
}
$this->sendMail();
$this->setToken();
$this->reset();
}
/*
* SECURITY
*/
/**
* Check if the honeypot field is untouched and if the time between this post,
* the page load and previous posts and the hourly post count are valid
* according to the settings, and set the form status accordingly.
*
* @param P01contact_form $form The submitted form
* @param array $post Sanitized p01-contact data of $_POST
* @return bool the result status
*/
private function checkSpam($post)
{
if (isset($post['totally_legit'])) {
$this->setStatus('error_honeypot');
return false;
}
$loads = Session::get('pageloads');
if (count($loads) > 1 && $loads[1] - $loads[0] < $this->config('min_sec_after_load')) {
$this->setStatus('error_pageload');
return false;
}
$lastpost = Session::get('lastpost', false);
if ($lastpost && time() - $lastpost < $this->config('min_sec_between_posts')) {
$this->setStatus('error_lastpost');
return false;
}
$postcount = Session::get('postcount', 0);
if (!$this->config('debug') && $postcount > $this->config('max_posts_by_hour')) {
$this->setStatus('error_postcount');
return false;
}
Session::set('lastpost', time());
Session::set('postcount', $postcount + 1);
return true;
}
/**
* Create an unique hash in Session
*/
private static function setToken()
{
Session::set('token', uniqid(md5(microtime()), true));
}
/**
* Get the token in Session (create it if not exists)
* @return string
*/
public function getToken()
{
if (!Session::get('token', false)) {
$this->setToken();
}
return Session::get('token');
}
/**
* Compare the POSTed token to the Session one
* @return boolean
*/
private function checkToken()
{
return $this->getToken() === $_POST['p01-contact_form']['token'];
}
/*
* RENDER
*/
/**
* Return the html display of the form
* @return string the <form>
*/
public function html()
{
$html = '<form action="'.PAGEURL.'#p01-contact'.$this->id.'" autocomplete="off" ';
$html .= 'id="p01-contact' . $this->id . '" class="p01-contact" method="post">';
if ($this->status) {
$html .= $this->htmlStatus();
}
if (!$this->sent) {
foreach ($this->fields as $field) {
$html .= $field->html();
}
if ($this->config('use_honeypot')) {
$html .= '<input type="checkbox" name="p01-contact_fields[totally_legit]" value="1" style="display:none !important" tabindex="-1" autocomplete="false">';
}
$html .= '<div><input name="p01-contact_form[id]" type="hidden" value="' . $this->id . '" />';
$html .= '<input name="p01-contact_form[token]" type="hidden" value="' . $this->getToken() . '" />';
$html .= '<input class="submit" type="submit" value="' . $this->lang('send') . '" /></div>';
}
$html .= '</form>';
if ($this->config('debug')) {
$html .= $this->debug(false);
}
return $html;
}
/**
* Return an html display of the form status
* @return string the <div>
*/
private function htmlStatus()
{
$statusclass = $this->sent ? 'alert success' : 'alert failed';
return '<div class="' . $statusclass . '">' . $this->lang($this->status) . '</div>';
}
/**
* Return P01contact_form infos.
* @return string
*/
public function debug($set_infos)
{
$out = '<div class="debug debug_form">';
static $post;
if ($set_infos) {
$post = $set_infos;
return;
}
if ($post) {
list($headers, $targets, $subject, $text_content, $html_content) = $post;
$out.= '<h3>Virtually sent mail :</h3>';
$out.= '<pre>'.htmlspecialchars($headers).'</pre>';
$out.= "<pre>Targets: $targets\nSubject: $subject</pre>";
$out.= "Text content : <pre>$text_content</pre>";
$out.= "HTML content : <div style=\"border:1px solid #ccc;\">$html_content</div>";
}
$infos = $this;
unset($infos->manager);
$out .= "<h3>p01contact form $this->id :</h3>";
$out .= preint($infos, true);
$out .= '</div>';
return $out;
}
/*
* MAIL
*/
/**
* Send a mail based on form
*
* Create the mail content and headers along to settings, form
* and fields datas; and update the form status (sent|error).
*/
public function sendMail()
{
$email = $name = $subject = $askcopy = null;
$tpl_data = (object) null;
$tpl_data->date = date('r');
$tpl_data->ip = $_SERVER["REMOTE_ADDR"];
$tpl_data->contact = $this->targets[0];
// fields
$tpl_data->fields = '';
foreach ($this->fields as $field) {
$tpl_data->fields .= $field->htmlMail();
switch ($field->type) {
case 'name':
$name = $field->value;
break;
case 'email':
$email = $field->value;
break;
case 'subject':
$subject = $field->value;
break;
case 'askcopy':
$askcopy = true;
break;
}
}
$html = $this->manager->renderTemplate('mail_template', $tpl_data);
$text = strip_tags($html);
if (empty($name)) {
$name = $this->lang('anonymous');
}
if (empty($subject)) {
$subject = $this->lang('nosubject');
}
// targets, subject, headers and multipart content
$targets = implode(',', $this->targets);
$encoded_subject = $this->encodeHeader($subject);
$mime_boundary = '----'.md5(time());
$headers = $this->mailHeaders($name, $email, $mime_boundary);
$content = $this->mailContent($text, 'plain', $mime_boundary);
$content .= $this->mailContent($html, 'html', $mime_boundary);
$content .= "--$mime_boundary--\n\n";
// debug
if ($this->config('debug')) {
$this->debug(array($headers, $targets, $subject, $text, $html));
return $this->setStatus('sent_debug');
}
// send mail
$success = mail($targets, $encoded_subject, $content, $headers);
// log
$this->manager->log(array(
date('d/m/Y H:i:s'), $targets, $subject, $name, $success ? 'success':'error'
));
if (!$success) {
return $this->setStatus('error');
}
if (!$email || !$askcopy) {
return $this->setStatus('sent');
}
// mail copy
$copy = mail($email, $encoded_subject, $content, $headers);
$this->setStatus($copy ? 'sent_copy' : 'sent_copy_error');
}
/**
* Return the mail headers
* @param string $name
* @param string $email
* @param string $mime_boundary
* @return string
*/
private function mailHeaders($name, $email, $mime_boundary)
{
$encoded_name = $this->encodeHeader($name);
$headers = "From: $encoded_name <no-reply@" . SERVERNAME . ">\n";
if ($email) {
$headers .= "Reply-To: $encoded_name <$email>\n";
$headers .= "Return-Path: $encoded_name <$email>";
}
$headers .= "\n";
$headers .= "MIME-Version: 1.0\n";
$headers .= "Content-type: multipart/alternative; boundary=\"$mime_boundary\"\n";
$headers .= "X-Mailer: PHP/" . phpversion() . "\n";
return $headers;
}
/**
* Return a multipart/alternative content part.
* @param string $content
* @param string $type the content type (plain, html)
* @param string $mime_boundary
* @return string
*/
private function mailContent($content, $type, $mime_boundary)
{
$head = "--$mime_boundary\n";
$head .= "Content-Type: text/$type; charset=UTF-8\n";
$head .= "Content-Transfer-Encoding: 7bit\n\n";
return $head.$content."\n";
}
/**
* Format a string for UTF-8 email headers.
* @param string $string
* @return string
*/
private function encodeHeader($string)
{
$string = base64_encode(html_entity_decode($string, ENT_COMPAT, 'UTF-8'));
return "=?UTF-8?B?$string?=";
}
/**
* Return array of valid emails from a comma separated string
* @param string $emails
* @return array
*/
public static function getValidEmails($emails)
{
return array_filter(explode(',', $emails), function ($email) {
return filter_var($email, FILTER_VALIDATE_EMAIL);
});
}
/**
* GETTERS / SETTERS
*/
/*
* Reset all fields values and errors
*/
public function reset()
{
foreach ($this->fields as $field) {
$field->value = '';
$field->error = '';
}
}
public function getTargets()
{
return $this->targets;
}
public function addTarget($tget)
{
if (in_array($tget, $this->targets) === false) {
$this->targets[] = $tget;
}
}
public function getField($id)
{
return $this->fields[$id];
}
public function getFields()
{
return $this->fields;
}
public function addField($field)
{
$this->fields[] = $field;
}
public function getStatus()
{
return $this->status;
}
public function setStatus($status)
{
if (!is_string($status)) {
return;
}
$this->status = $status;
if (substr($status, 0, 4) == 'sent') {
$this->sent = true;
}
}
public function getId()
{
return $this->id;
}
public function config($key)
{
return $this->manager->config($key);
}
public function lang($key)
{
return $this->manager->lang($key, $this->lang);
}
}
@@ -0,0 +1,107 @@
<?php
/**
* p01-contact - A simple contact forms manager
*
* @link https://github.com/nliautaud/p01contact
* @author Nicolas Liautaud
* @package p01contact
*/
namespace P01C;
class Session
{
private static $key = 'p01contact';
private static function init()
{
if (session_id() === '') {
session_start();
}
if (!self::exists()) {
$_SESSION[self::$key] = [];
}
}
/**
* Return if the session data exists, or if the given key exists.$_COOKIE
*
* @param string $key
* @return bool
*/
public static function exists($key = null)
{
$sessionExist = !empty($_SESSION) && !empty($_SESSION[self::$key]);
if ($key === null) {
return $sessionExist;
}
return $sessionExist && isset($_SESSION[self::$key][$key]);
}
/**
* Set the given key to the given value.
*
* @param string $key
* @param mixed $val
* @return void
*/
public static function set($key, $val)
{
if (!self::exists()) {
self::init();
}
$_SESSION[self::$key][$key] = $val;
}
/**
* Get the given key data.
*
* @param string $key
* @param mixed $default (optional) Value to return if the key doesn't exist.
* @return mixed `$default` or `null`
*/
public static function get($key, $default = null)
{
if (!self::exists($key)) {
return $default;
}
return $_SESSION[self::$key][$key];
}
/**
* Add value to the array named key and shift old
* entries until the array is of given size.
*
* @param string $key
* @param mixed $val
* @param integer $size
* @return void
*/
public static function stack($key, $val, $size = 2)
{
if (!self::exists()) {
self::init();
}
$arr = self::get($key);
if (!isset($arr)) {
$arr = [];
}
if (!is_array($arr)) {
return;
}
array_push($arr, $val);
while (count($arr) > $size) {
array_shift($arr);
}
self::set($key, $arr);
}
/**
* Return the session data, in html.
*
* @return string
*/
public static function report()
{
if (!self::exists()) {
return;
}
$out = '<h3>$_SESSION :</h3>';
$out.= preint($_SESSION, true);
return $out;
}
}
@@ -0,0 +1,178 @@
<style>
.p01contact_panel * {
box-sizing: border-box;
width: auto;
}
.p01contact_panel {
overflow: auto;
}
.p01contact_panel .version {
color: #aaa;
float: right;
font-weight: bold;
}
.p01contact_panel fieldset {
border: 0;
width: 100%;
margin: 2em 0;
}
.p01contact_panel .field {
display: flex;
align-items: center;
justify-content: space-between;
margin: 1em 0 0 0;
}
.p01contact_panel .field > :first-child {
width: 48%;
}
.p01contact_panel .field > :last-child {
width: 48%;
}
.p01contact_panel textarea {
height: 3em;
}
.p01contact_panel strong {
display: block;
}
.p01contact_panel p {
margin: 1em 0;
line-height: 1.2;
}
.p01contact_panel p,
.p01contact_panel .field em {
color: #999;
font-weight: normal;
font-size: .95em;
font-style: normal;
}
.p01contact_panel em label {
display: inline;
font-size: 1em;
font-weight: normal;
color: #888;
padding: 0 .5em;
}
.p01contact_panel .text.left {
width: 7em;
margin-right: 2em;
}
.p01contact_panel .submit {
float: right;
}
.logs {
margin-top: 5em;
}
.logs table {
max-height: 30em;
}
</style>
<div class="p01contact_panel">
<form action="" method="post">
<input class="submit" type="submit" value="Save settings" />
<h3>{{lang.config_title}}</h3>
<p>
<a href="{{REPOURL}}">{{lang.repo}}</a> -
<a href="{{WIKIURL}}">{{lang.wiki}}</a> -
<a href="{{ISSUESURL}}">{{lang.issues}}</a>
<span class="version">v{{version}}</span>
</p>
<label class="field">
<div><strong>{{lang.default_email}}</strong><em>{{lang.default_email_sub}}</em></div>
<textarea name="p01-contact[settings][default_email]">config(default_email)</textarea>
</label>
<label class="field">
<div><strong>{{lang.lang}}</strong><em>{{lang.lang_sub}} {{default_lang}}</em></div>
<select class="text" name="p01-contact[settings][lang]">{{langsoptions}}</select>
</label>
<label class="field">
<div><strong>{{lang.message_len}}</strong><em>{{lang.message_len_sub}}</em></div>
<input type="number" class="text" name="p01-contact[settings][message_len]" value="config(message_len)" size=3 maxlength=3 min=0 />
</label>
<label class="field">
<div><strong>{{lang.default_params}}</strong><em>{{lang.default_params_sub}}</em></div>
<textarea name="p01-contact[settings][default_params]">config(default_params)</textarea>
</label>
<label class="field">
<div><strong>{{lang.separator}}</strong><em>{{lang.separator_sub}}</em></div>
<input type="text" class="text" name="p01-contact[settings][separator]" value="config(separator)"/>
</label>
<label class="field">
<div><strong>{{lang.logs_count}}</strong><em>{{lang.logs_count_sub}}</em></div>
<input type="number" class="text" name="p01-contact[settings][logs_count]" value="config(logs_count)" min=0 />
</label>
<fieldset>
<h3>{{lang.checklists}}</h3>
<p>{{lang.checklists_sub}}</p>
<div class="field">
<div>
<input type="text" class="text left" name="p01-contact[settings][checklist][0][name]" value="config(checklist,0,name)"/>
<em>
<label><input name="p01-contact[settings][checklist][0][type]" type="radio" value="blacklist" {{cl0bl}}/> {{lang.blacklist}}</label>
<label><input name="p01-contact[settings][checklist][0][type]" type="radio" value="whitelist" {{cl0wh}}/> {{lang.whitelist}}</label>
</em>
</div>
<textarea name="p01-contact[settings][checklist][0][content]">config(checklist,0,content)</textarea>
</div>
<div class="field">
<div>
<input type="text" class="text left" name="p01-contact[settings][checklist][1][name]" value="config(checklist,1,name)"/>
<em>
<label><input name="p01-contact[settings][checklist][1][type]" type="radio" value="blacklist" {{cl1bl}}/> {{lang.blacklist}}</label>
<label><input name="p01-contact[settings][checklist][1][type]" type="radio" value="whitelist" {{cl1wh}}/> {{lang.whitelist}}</label>
</em>
</div>
<textarea name="p01-contact[settings][checklist][1][content]">config(checklist,1,content)</textarea>
</div>
<div class="field">
<div>
<input type="text" class="text left" name="p01-contact[settings][checklist][2][name]" value="config(checklist,2,name)"/>
<em>
<label><input name="p01-contact[settings][checklist][2][type]" type="radio" value="blacklist" {{cl2bl}}/> {{lang.blacklist}}</label>
<label><input name="p01-contact[settings][checklist][2][type]" type="radio" value="whitelist" {{cl2wh}}/> {{lang.whitelist}}</label>
</em>
</div>
<textarea name="p01-contact[settings][checklist][2][content]">config(checklist,2,content)</textarea>
</div>
</fieldset>
<fieldset>
<h3>{{lang.Security}}</h3>
<label class="field">
<div><strong>{{lang.use_honeypot}}</strong><em>{{lang.use_honeypot_sub}}</em></div>
<input type="checkbox" name="p01-contact[settings][use_honeypot]" {{honeypotchecked}}/>
</label>
<label class="field">
<div><strong>{{lang.min_sec_after_load}}</strong><em>{{lang.min_sec_after_load_sub}}</em></div>
<input type="number" class="text" name="p01-contact[settings][min_sec_after_load]" value="config(min_sec_after_load)" min=0 />
</label>
<label class="field">
<div><strong>{{lang.min_sec_between_posts}}</strong><em>{{lang.min_sec_between_posts_sub}}</em></div>
<input type="number" class="text" name="p01-contact[settings][min_sec_between_posts]" value="config(min_sec_between_posts)" min=0 />
</label>
<label class="field">
<div><strong>{{lang.max_posts_by_hour}}</strong><em>{{lang.max_posts_by_hour_sub}}</em></div>
<input type="number" class="text" name="p01-contact[settings][max_posts_by_hour]" value="config(max_posts_by_hour)" min=0 />
</label>
<p>{{lang.captcha_info}}</p>
<label class="field">
<div><strong>{{lang.recaptcha_public_key}}</strong><em>{{lang.recaptcha_public_key_sub}}</em> (<a href=\"https://www.google.com/recaptcha/admin\">reCaptcha admin</a>)</div>
<input type="text" class="text" name="p01-contact[settings][recaptcha_public_key]" value="config(recaptcha_public_key)"/>
</label>
<label class="field">
<div><strong>{{lang.recaptcha_secret_key}}</strong><em>{{lang.recaptcha_secret_key_sub}}</em> (<a href=\"https://www.google.com/recaptcha/admin\">reCaptcha admin</a>)</div>
<input type="text" class="text" name="p01-contact[settings][recaptcha_secret_key]" value="config(recaptcha_secret_key)"/>
</label>
</fieldset>
<fieldset>
<h3>{{lang.debug}}</h3>
<label class="field">
<div><strong>{{lang.disable}}</strong><em>{{lang.disable_sub}}</em></div>
<input type="checkbox" name="p01-contact[settings][disable]" {{disablechecked}}/>
</label>
<label class="field">
<div><strong>{{lang.debug}}</strong><em>{{lang.debug_sub}} {{lang.debug_warn}}</em></div>
<input type="checkbox" name="p01-contact[settings][debug]" {{debugchecked}}/>
</label>
</fieldset>
<input class="submit" type="submit" value="Save settings" />
</form>
</div>
@@ -0,0 +1,28 @@
<table width="100%" style="min-height: 100%; max-width: 40em; margin: auto">
<tr style="height: 3em;">
<td colspan="3">
<h2 style='margin: .5em 0; font-size: 1.5em; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;'>
{{lang.email_title}}
<a href="{{PAGEURL}}">{{SERVERNAME}}</a>
</h2>
</td>
<tr style="height: 2em; font-size: .875em; color: #888888">
<td style="text-align: left">{{date}}</td>
<td style="text-align: center"><a href="{{PAGEURL}}" style="color:#888888">{{PAGEURI}}</a></td>
<td style="text-align: right">{{ip}}</td>
</tr>
<tr>
<td colspan="3" style="padding: 4em 0">{{fields}}</td>
</tr>
<tr style="height: 2em; font-size: .875em">
<td colspan="2">
If this mail should not be for you, please contact
<a href="mailto:{{contact}}">{{contact}}</a>.
</td>
<td style="text-align:right">
<a style="color: #cccccc; text-decoration: none" href="{{REPOURL}}">
p01-contact v{{VERSION}}
</a>
</td>
</tr>
</table>
File diff suppressed because it is too large Load Diff