Archiv autora: Jan Kocmánek

Jak NETTE šetří práci

Občas potřebuji udělat jen drbku na webu, který na NETTE neběží a pak nastane otázka, zda to dělat ještě v pure PHP a nebo nějak do toho komponovat NETTE. U mě je odpověď jasná. NETTE!

Zadání:
Přidat na stávající web formulář pro odeslání mailů.

Řešení:
Kdekoliv je libo (někdo plácá HTML s PHP, někdo typy obsahu dělí mezi šablonu a další soubory, …) si vygenerujeme formulář s plnou podporou NETTE.

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
// podrobněji toto řešení rozebírám v jiném článku
$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');

Pak kdekoliv chci formulář zobrazit tak napíši jen:

echo $form;

Pro JS validaci (není nutná) stačí nahrát a nalinkovat soubor netteForm.js:

<script type="text/javascript" src="../_js/netteForms.js"></script>
 
<script type="text/javascript">
	// řešení pro antispam popisuji jinde
	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";
</script>

Zpracování formuláře a odeslání ho na mail jde stejně jednoduše:

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();
}</info></web>

Jak dlouho byste toto dělali v čistém PHP?

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é :).

Jak se připojit z PHP pomocí CURL k SSL (HTTPS) adrese

K zaslání klasického požadavku stačí následující kód:

if ($curl = curl_init(ADRESA)) {
	curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
	$content = curl_exec($curl);
	curl_close($curl);
}

Ale pokud je adresa chráněna pomocí SSL (začíná https://), tak tento kód vrací chybu:
SSL certificate problem, verify that the CA cert is OK. Details:
error:14090086:SSL routines:SSL3_GET_SERVER_CERTIFICATE:certificate verify failed
.
Řešení je jednoduché. Stačí přidat řádek:
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);.
Takže výsledný kód vypadá následovně:

if ($curl = curl_init(ADRESA)) {
	curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
	curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
	$content = curl_exec($curl);
	curl_close($curl);
}

Jak se nepíše webová služba

Dostal jsem nabídku na udělání portálu pro zjišťování insolvence jak osob tak firem.
Úloha by se to zdála celkem snadná po tom co jsem zjistil, že existuje webová služba ISIR.
Aniž by člověk musel být analyticky zdatný, tak hned čeká, že pod touto webovou službou budou existovat například metody getPerson(rodne_cislo) a getCompany(IC) a jejich návratová hodnota bude TRUE/FALSE podle toho zda daný subjekt v insolvenci je nebo není.
Po seznámení se se službou končí logika na tom, že webová služba má dvě metody. Ale nejmenují se getPerson(rodne_cislo) a getCompany(IC), ale neočekávaně getIsirPub0012(long_1) a getIsirPub001(Calendar_1). Tady je otázka, zda daná firma, která tuto službu ministerstvu dodala měla tak nízký rozpočet, že si nemohla dovolit tyto metody ani smysluplně pojmenovat o argumentech raději nemluvě.
Ale tady absurdita teprve začíná…
Dále je třeba se zabývat co vlastně tyto metody poskytují. První zmiňovaná (getIsirPub0012(long_1)) vrátí maximálně 1000 záznamů (co je v tomto případě záznam napíšu později) s ID větším nebo rovno než je parametr long_1. Než začnete přemýšlet k čemu to může sloužit, tak si počkejte na vysvětlení co je vlastně obsahem záznamu. Ale než toto objasním tak ještě vysvětlím druhou metodu. Druhá metoda (getIsirPub001(Calendar_1)) očekává parametr datum ve formátu 2011-11-24T00:00:00.000Z a vrací maximálně 1000 záznamů vzniklých po tomto čase. Hmm…

A teď velké objasnění co je vlastně obsahem záznamu. Záznam je jen „doklad“ o změně stavu. Takže pokud se těchto záznamů během celého soudního řízení vytvoří 100, tak musím zpracovávat 100 nedůležitých záznamů abych čekal na ten poslední a věděl jestli daný subjekt je v insolvenci a nebo není.

Pokud to zatím z textu nevyplývá, tak je třeba zmínit, že není možné jednoduše zjistit zda subjekt XY v insolvenci je a nebo není. Jediný způsob je si stáhnout kompletní databázi a následně to vyhledat až v ní. V databázi je ke dnešku přes 3.000.000 záznamů.

A jen perličkou na konec uzavřu toto povídání zmínkou o dokumentaci. Poslední aktualizace (k mému údivu) byla zrovna dneska, kde přibyl graf přechodů mezi stavy a informace o připojení. Graf je dost nepřehledný, ale ten vychází z procesu a lepší něco než nic. Informace o připojení mi přijde jako věc, která tu snad musela být od začátku (?!?), tak proč se v dokumentu objevila až po čtyřech letech a ještě s jednou zásadní chybou (viz obrázek).
ISIR připojení k webové službě

To jako fakt někdo myslí vážně, že takto má fungovat nějaká webová služba?

Zamezní propagace submitu odeslání vnořeného formuláře

Pokud potřebujeme vyvolávat submit událost vnořeného formuláře a zároveň omezit odeslání vnějšího, tak musíme použít následující postup.
Pro názornost máme následující příklad:

<form id="frm">
	<!-- nějaké formulářové pole -->
	<!-- kus HTML -->
	<!-- následuje INPUT, který se odesílá pomocí AJAXu, jak na click, tak na klávesu enter -->
	<input name="zprava" id="zprava" type="text" />
	<input type="button" id="sb_submit" value="Odeslat" />
	<!-- kus HTML -->
</form>

A pomocí následujícího JavaScriptu docílíme kýžené funkcionality.

$(function() {
	var enter = false;		// pomocná proměnná
	$('#frm').submit(function(e) {
		if (enter) {		// pokud byl stisknut enter v poli, které nesouvisí s hlavním formulářem (viz nastavení níže) ...
       			enter = false;	// ... proměnou vynulujeme pro další použití ...
       			return false;	// ... tak odeslání vnějšího formuláře neprovádíme
		}
	});
 
	// odeslání na stisk klávesy enter
	$('#zprava').keydown(function(e) {
		if (e.keyCode == 13) {	// došlo ke zmáčknutí klávesy enter
			enter = true;	// nastavíme pomocnou proměnou na TRUE
			send();		// odešleme data
		}
	});
 
	// odeslání na událost onClick
	$('#sb_submit').click(function(e) {
		send();
	});
});