Jak neotravovat uživatele s antispamem? – realizace s podporou NETTE

V článku jsem nastínil, jak chránit webový formulář antispamem bez zbytečné interakce uživatelů.

Jak to ale propojit s NETTE a jeho generací formulářů.

Příklad je napsán bez použití kompletního frameworku. Používám NETTE jen jako knihovnu.

PHP:

include_once $_SERVER['DOCUMENT_ROOT'] . '/_libs/nette.min.php';
 
use	Nette\Mail\Message,
	Nette\Forms\Form;
 
// vytvoření formuláře		
$form = new Form;
$form->addText('jmeno', 'Jméno a příjmení *:')
	->setRequired('Zadejte prosím jméno');
$form->addText('mail', 'E-mail:');
$form->addText('tel', 'Telefon:')
	->addConditionOn($form['mail'], Form::EQUAL, '')
		->addRule(Form::FILLED, 'Zadejte prosím alespoň jeden z kontaktů');
 
$form['mail']->addConditionOn($form['tel'], Form::EQUAL, '')
	->addRule(Form::FILLED, 'Zadejte prosím alespoň jeden z kontaktů')
	->addRule(Form::EMAIL, 'E-mail nemá správný formát');
 
$form->addSelect('zajem', 'Mám zájem o:', array('sádrokartony, rekonstrukce' => 'sádrokartony, rekonstrukce', 'malby, nátěry' => 'malby, nátěry', 'ostatní' => 'ostatní'));
$form->addTextArea('pozn', 'Poznámka:', 40, 4);
 
// antispam
// Nelze řešit přes RAND, jelikož po odeslání by se nám hodnoty změnily, toto beru jako zajímavé "konstanty", které mají platnost jeden den.
// Nápady jak toto lépe řešit uvítám v diskuzi.
$c1 = date('j')+3;
$c2 = date('N')+2;
$s = $c1 + $c2;
$form->addText('soucet', sprintf('Zadejte součet %s+%s *:', $c1, $c2))
	->setRequired('Je třeba zadat součet jako ochranu proti SPAMu')
	->addRule(Form::EQUAL, 'Je třeba zadat součet jako ochranu proti SPAMu', $s);
$form->addHidden('c1', $c1);
$form->addHidden('c2', $c2);
$form->addSubmit('send', 'Odeslat');
 
// zpracování formuláře po odeslání
if ($form->isSuccess()) {
	$val = $form->getValues();
	$mail = new Message;
 
	$from = ($val['mail'] == '') ? 'Z webu <web @nejaky-web.cz>' : $val['jmeno'] . ' < ' . $val['mail'] . '>';
	$mail->setFrom($from)
		->addTo('Info <info @nejaky-web.cz>')
		->setSubject('Poptávka z webu')
		->setBody(sprintf("Dobrý den,\nJméno: %s\nE-mail: %s\nTelefon: %s\nZájem o: %s\nPoznámka:\n%s", $val['jmeno'], $val['mail'], $val['tel'], $val['zajem'], $val['pozn']))
		->send();
 
	// nesouvisí s příkladem - vložíme poděkování
	include $_SERVER['DOCUMENT_ROOT'] . '/templates/formular-odeslan.php';
} else {
	echo $form;
}

Vypíchnu raději bokem samotnou podstatu antispamového řešení:

// antispam
// Nelze řešit přes RAND, jelikož po odeslání by se nám hodnoty změnily, toto beru jako zajímavé "konstanty", které mají platnost jeden den.
// Nápady jak toto lépe řešit uvítám v diskuzi.
$c1 = date('j')+3;
$c2 = date('N')+2;
$s = $c1 + $c2;
$form->addText('soucet', sprintf('Zadejte součet %s+%s *:', $c1, $c2))
	->setRequired('Je třeba zadat součet jako ochranu proti SPAMu')
	->addRule(Form::EQUAL, 'Je třeba zadat součet jako ochranu proti SPAMu', $s);
$form->addHidden('c1', $c1);
$form->addHidden('c2', $c2)

Obslužný JS, který nám v případě zapnutého JS skryje antispamový prvek

<script type="text/javascript">
	document.getElementById("frm-soucet").value = parseInt(document.getElementById("frm-c1").value) + parseInt(document.getElementById("frm-c2").value);
	document.getElementById("frm-soucet").parentNode.parentNode.style.display = "none"; // musíme se dostat až ke značce TR
</script>

Jak na multiselect v NETTE + dibi

Potřeboval jsem použít multiselectu v e-shopu, který právě dělám, pro určení souvisejícího zboží a narazil jsem na pár zádrhelů, které zde popíši.

1. Jak naplnit multiselect daty, při použití skupin
Toto má být výsledek
Multiselect

Je třeba dodat data ve formátu:

array (
"Bombóny" => array (
   15 => "Hašlerky"
   16 => "Bompary"
),
"Lízátka" => array (
   11 => "Chupachups"
),
"Čokolády" => array (
   12 => "Milka"
   17 => "Orion"
),
"Ostatní" => (
   14 => "Špalet"
   18 => "Pendrek"
)
)

Jak tento formát získat záleží zejména na struktuře databáze. Předpokládám, že pokud je „ideální“, tak by to šlo pomocí vhodné asociace (viz příklad níže). Vzhledem k tomu, že moje databáze má jinou strukturu a kýženému výsledku jsem se musel dobrat mnohem složitěji (iterace nad daty, dotahování dalších dat pomocí dalších selectů, rekurze, …), tak následující příklad je jen myšlenka, jak by to mohlo fungovat (chybí tam například výběr IDcek a následující nutné UNSETy).

   $res = dibi::query('SELECT [nazev_menu], [nazev_produktu] FROM [menu] JOIN [zbozi] USING ([kod_menu]) ORDER BY [nazev_menu], [nazev_produktu]');
   $res->fetchAssoc('nazev_menu', '=', 'nazev_produktu');

Po získání dat stačí již jednoduše zavolat:

    $form->addMultiSelect('items', 'Související produkty:', $items);

2. Vybrání default hodnot
NETTE očekává pro formulářový prvek multiselect formát:

    array(id1, id2, id3);

Ten získáme malým trikem pomocí fetchPairs (příklad mojí konkrétní funkce z modelu):

    public function getRelated($id)
    {
        return $this->connection->select('id_2')
	                        ->from(self::TABLE_PAGES_RELATED)
	                        ->where('id_1=', $id)
	                        ->fetchPairs('id_2', 'id_2'); // tento řádek je ten důležitý
    }

A následně v presenteru:

	$model = new RelatedModel;
	$items = $model->getRelated($id);
 
	$form = $this->getComponent('itemsForm');
	$form->setDefaults(array('items'=> $items));

3. Uložení získaných dat
NETTE nám z formuláře vrátí data ve formátu:

"items" => array (
   0 => "15"
   1 => "11"
)

ale takto je nelze předhodit SQL dotazu pro INSERT a musím je tedy přetransformovat do tvaru ID=>ID. Což vypadá ve výsledku takto:

"items" => array (
   array (
      "id_1" => "14",
      "id_2" => "15"
   ),
   array (
      "id_1" => "14",
      "id_2" => "11"
   )
)

kde id_1 je id editovaného záznamu a id_2 jsou související id (ze stejné tabulky jako id_1).
Takový formát se již dá předhodit dibi. Konkrétní funkce z mého modelu vypadá následovně:

    public function setRelated($id, $data)
    {
	dibi::query('DELETE FROM [pages_related] WHERE [id_1]=%i', $id); // vyčistíme si data pro dané ID
	dibi::query('INSERT INTO [pages_related] %ex', $data); // uložíme si nové související položky. %ex provede expanzi pole pro multiinsert		
    }

Zde je i ukázáno jakým způsobem provést multiinsert dat, aby nebylo nutno iterovat nad daty a provádět jednotlivé inserty.

A nyní už chybí jen kus kódu, který transformuje data na výše popsaný formát. pro přehlednost ukážu celou metodu pro zpracování formulářových dat.

    public function itemsFormSubmitted($form)
    {
		$data = $form->getValues();
		$model = new RelatedModel;
 
		$arr = array();
		foreach($data['items'] as $key => $value) {
			$arr[$key]['id_1'] = $data['id'];
			$arr[$key]['id_2'] = $value;
		}
		$model->setRelated($data['id'], $arr);
 
		$this->flashMessage('Byly aktualizovány související produkty.', 'info');
		$this->redirect('this');
	}

Vše!

Autentizace (přihlášení) v NETTE pomocí LDAP – omezení uživatelů

Navazuji na předchozí návod. Ale navíc jsem řešil, jak co nejjednodušeji zajistit, aby do aplikace mohla jen určitá podmnožina uživatelů z LDAP (u mě konkrétně Active Directory).
Možnosti:

  • Využít vlastnosti struktury či atributů Active Directory – u mě nepřipadá v úvahu
  • Mít další evidenci v databázi webové aplikace. Například s INDEXem na atribut $username. – Zbytečná evidence navíc, které se musí spravovat.
  • Mít ve výčtu povolené $username.
    	$allow_usernames = array('honza', 'mojmir', 'patrik');

    A dále kontrolovat, zda přihlašovaný je v tomto poli. – Evidence navíc a při změně oprávnění je třeba magické úpravy v aplikační logice.

  • Přidat kontrolní heslo, které budou znát jen administrátoři. – Zdá se, že je zbytečná další vazba na Active Directory, proč prostě nestačí toto admin heslo, ale pokud vypnu uživatele v Active Directory, tak automaticky zamezím přístup i do administrace, ale bez této kombinace by to nešlo.

Authenticator:

< ?php
// pro PHP 5.3
// use Nette\Object, Nette\Security\IAuthenticator, Nette\Security\AuthenticationException, Nette\Security\Identity;
 
class MyAuthenticator extends Object implements IAuthenticator {
 
	public function authenticate(array $credentials) {
		$username = $credentials[self::USERNAME];
		$password = $credentials[self::PASSWORD];
		$control_password = $credentials['extra'];
 
		$ldap_conn = ldap_connect ('ldap://server.domena.cz');
		if ($ldap_conn) {
			$ldapbind = @ldap_bind ($ldap_conn, $username . '@domena.tld', $password);
			ldap_unbind ($ldap_conn);
			if ($ldapbind) {
				if ($control_password === 'nejake_zvolene_kontrolni_heslo') {
					return new Identity('Správce.', 'Administrátor', $ldapbind);
				} else {
					throw new AuthenticationException("Účet nemá dostatečná práva.", self::NOT_APPROVED);
				}
			} else {
				throw new AuthenticationException("Špatné jméno nebo heslo.", self::INVALID_CREDENTIAL);
			}
		} else {
			throw new AuthenticationException("Ověřovací server není dostupný.", self::FAILURE);
		}
	}
}

Použití v presenteru (zatím ve stínu starého NETTE):

	protected function createComponentLogin()
	{
		$form = new AppForm;
		$form->addText('login', 'Přihlašovací jméno:')
			->addRule(Form::FILLED, 'Pole "Přihlašovací jméno" je povinné.');
		$form->addPassword('password', 'Heslo:')
			->addRule(Form::FILLED, 'Pole "Heslo" je povinné.');
		$form->addPassword('control', 'Kontrolní heslo:')
			->addRule(Form::FILLED, 'Pole "Kontrolní heslo" je povinné.');
        	// Obrana proti CSRF
		$form->addProtection('Vypršel čas pro přihlášení.');
		$form->addSubmit('send', ' Přihlásit ');
 
		$form->onSubmit[] = array($this, 'formLoginSubmitted');
 
		$this->addComponent($form, "login");
	}
 
	function formLoginSubmitted($form) {
		$user = Environment::getUser();
		$user->setExpiration('+ 15 minutes');
		$values = $form->getValues();
 
		try {
			// pokusíme se přihlásit uživatele...
			// tady je to nejdůležitější, třetí parametr LOGINu
			$user->login($values['login'], $values['password'], $values['control']);
			// ...a v případě úspěchu přesměrujeme na další stránku
			$this->flashMessage('Přihlášení proběhlo úspěšně.');
			$this->redirect('Admin:default');
		} catch (AuthenticationException $e) {
			$this->flashMessage($e->getMessage(), 'validation');
		}
	}

Autentizace (přihlášení) v NETTE pomocí LDAP

Ne, že by to bylo něco složitého, pro někoho kdo má s LDAP již nějaké zkušenosti, ale myslím, že komplexní návod se hodí.

< ?php
// pro PHP 5.3
// use Nette\Object, Nette\Security\IAuthenticator, Nette\Security\AuthenticationException, Nette\Security\Identity;
 
class MyAuthenticator extends Object implements IAuthenticator {
 
	public function authenticate(array $credentials) {
		$username = $credentials[self::USERNAME];
		$password = $credentials[self::PASSWORD];
 
		$ldap_conn = ldap_connect ('ldap://overovaci.server.cz');
		if ($ldap_conn) {
			$ldapbind = @ldap_bind ($ldap_conn, $username . '@domena.tld', $password);
			ldap_unbind ($ldap_conn);
			if ($ldapbind) {
				return new Identity('Správce.', 'Administrátor', $ldapbind);
			} else {
				throw new AuthenticationException("Špatné jméno nebo heslo.", self::INVALID_CREDENTIAL);
			}
		} else {
			throw new AuthenticationException("Ověřovací server není dostupný.", self::FAILURE);
		}
	}
}

Pro jednoduchost se v tomto příkladu neřeší další správa rolí, nezjišťují se podrobnosti o uživateli atd. Možná jindy…

Jak v NETTE změnit řazení tabulky klikem na záhlaví sloupců

Klasická situace, ale není to tak jednoduché jak se zdá. Co všechno musíme řešit?

  1. podle kterého sloupce řadíme
  2. jak je naposledy seřazen

Pokud toto už máme napsáno, tak dále musíme řešit různé překliky mezi sloupci.
Jdeme na věc!
Presenter:

< ?php
class AdminPresenter extends AdminBasePresenter {
 
	/** @persistent int */ // označuje proměnou níže jako persistentní - proměnná se sama přenáší mezi kliky v rámci presenteru (náhrada bezestavovosti protokolu HTTP)
	public $p_sort = 1; //  proměnná pro uložením posledního řazeného sloupce
 
	/* 
	 * $sort Integer - pořadové číslo sloupce z dotazu podle kterého se má řadit
	 * $by String - způsob řazení
	 */ 
	public function renderTabulka($sort = 1, $by = 'ASC') { 
		$by = ($sort != $this->p_sort) ? 'ASC' : $by; // zjistíme jestli řadíme stejný sloupec jako minule
		$this->template->rows = dibi::fetchAll('SELECT ... FROM ... ORDER BY %i %sql', $sort, $by); // seřadíme
		$this->template->by = ($by == 'ASC') ? 'DESC' : 'ASC'; // pošleme proměnou s opačným způsobem řazení do templatu 
		$this->p_sort = $sort; // uložíme sloupec, podle kterého jsme řadili
	}

Template:

<table>
<tr>
	<td><a href="{plink sklad, 1, $by}">Sloupec 1</a></td>
	<td><a href="{plink sklad, 2, $by}">Sloupec 2</a></td>
	<td><a href="{plink sklad, 3, $by}">Sloupec 3</a></td>
</tr>
{if count($rows) > 0}
	{foreach $rows as $row}
		<tr>
			<td> {$row['sloupec1']} </td>
			<td> {$row['sloupec2']} </td>
			<td> {$row['sloupec3']} </td>
		</tr>
	{/foreach}
{else}
    <tr><td colspan="3">V databázi se nenalézá žádný záznam.</td></tr>
{/if}
</table>

Doufám, že to je pochopitelné :).