Wyłączanie akcji w generatorze Symfony

Generator modułów administracyjnych w Symfony nie umożliwia wybrania akcji, które dany moduł ma wykonywać, zawsze dostajemy pełen zestaw CRUD. Czasami zachodzi potrzeba zablokowania niektórych akcji, np. w wypadku logów bezpieczeństwa sens ma jedynie index (przeglądanie listy), a jakakolwiek modyfikacja powinna być zabroniona.

Odnośniki widoczne na liście można łatwo ukryć edytując plik generator.yml, wystarczy domyślne akcje zastąpić pustymi listami.

generator:
  param:
    config:
      list:
        title:             Permission changes log
        actions:           {}
        object_actions:    {}
        batch_actions:     {}
      filter:
        display:           [agent_user_name, subject_user_name, permission_name, created_at]

Akcje można jednak dalej wykonywać ręcznie wpisując URL, dlatego należy je zablokować w kontrolerze.

class permission_logActions extends autoPermission_logActions
{
  public function preExecute()
  {
    $this->forward404If($this->getActionName() !== 'index', 'Action disabled');
    parent::preExecute();
  }
}

Zapisywanie osadzonych formularzy w Symfony

Zapisywanie osadzonych formularzy w Symfony działa inaczej niż intuicyjnie zakładałem. Co ciekawsze, już raz się z tym kiedyś zetknąłem, ale było to na tyle dawno, że zapomniałem rozwiązania.

Załóżmy, że w zwykłym formularzu dziedziczącym z klasy sfForm osadzam formularz oparty na modelu z bazy danych czyli dziedziczący z sfFormDoctrine.

class OnePageCheckoutForm extends sfForm
{
  public function configure()
  {
    $this->widgetSchema['same_address'] = new sfWidgetFormInputCheckbox();
    $this->validatorSchema['same_address'] = new sfValidatorBoolean();

    $this->embedForm('customer', new CustomerForm());
    $this->embedForm('shipment_address', new AddressForm());
    $this->getWidgetSchema()->setNameFormat('checkout_form[%s]');
  }
}

W kontrolerze chcę zapisać dane z osadzonych formularzy, ale oczywiście klasa OnePageCheckoutForm nie ma metody save(). W pierwszym odruchu napisałem mniej więcej coś takiego:

public function executeCheckout(sfWebRequest $request)
{
  if (!$this->getUser()->hasCart())
  {
    return $this->redirect('@cart');
  }
  $this->cart = $this->getUser()->getCart();

  $this->form = new OnePageCheckoutForm();
  if ($request->getMethod() == 'POST')
  {
    $this->form->bind($request->getParameter($this->form->getName()), $request->getFiles($this->form->getName()));
    if ($this->form->isValid())
    {
      $values = $this->form->getValues();
      $this->form->getEmbeddedForm('customer')->save();
      $this->form->getEmbeddedForm('shipment_address')->save();

      $this->cart->setCustomerId($this->form->getEmbeddedForm('customer')->getObject()->getId());
      $this->cart->save();
      $this->redirect('@cart_payment_process');
    }
  }
}

Ku memu zaskoczeniu zobaczyłem wyjątek, z którego wynikało, że zapis formularza customer się nie powiódł, bo formularz nie zawiera danych. Dla pewności wrzuciłem w kod kilka var_dumpów:

// wynik: dane dla całego formularza łącznie z osadzonymi
var_dump($this->form->getValues());
// wynik: false
var_dump($this->form->getEmbeddedForm('customer')->isBound());
// wynik: pusta tablica
var_dump($this->form->getEmbeddedForm('customer')->getObject()->toArray());

Jak się okazało, formularz klasy sfForm nie aktualizuje obiektów w osadzonych formularzach, trzeba to wykonać samemu:

$this->form->getEmbeddedForm('customer')
  ->updateObject($this->form->getValue('customer'));

Można to zrobić w kontrolerze, ja ze względów konwencjonalno-estetycznych dopisałem do klasy OnePageCheckoutForm metody updateEmbeddedForms() i saveEmbeddedForms(), które są z grubsza kopią tak samo nazwanych metod z klasy sfFormObject.

Na poły wirtualne środowisko developerskie

Wymiana dysku systemowego na SSD była dla mnie okazją do wprowadzenia zmian w domowym Linuksie. Jedną z nich było postawienie zupełnie nowego środowiska developerskiego. Z powodzeniem używam go już od niemal dwóch miesięcy i jestem z niego zadowolony, więc żywię skromną nadzieję, że opisując je może komuś pomogę.

Przez długie lata jeden system służył mi do Internetu, programowania, rozrywki i wszelkich innych zastosowań. Miałem na nim zainstalowany komplet przeglądarek, wybór baz danych (m.in. PostgreSQL, MySQL, MongoDB), trochę serwerów (Lighttpd, ProFTPd, IceCast), interpretery i kompilatory różnych języków programowania (PHP, Python, g++…) plus sporo różnych narzędzi. Takie rozwiązanie ma kilka wad:

  • Na desktopie mam przeważnie nowsze wersje programów niż na serwerze produkcyjnym, więc czasami pojawiają się problemy z kompatybilnością.
  • Bazy, serwery i inne bajery pożerają zasoby nawet jeśli ich w danej chwili nie potrzebuję, bo nie programuję.
  • Kompilowanie programów ze źródeł, instalowanie dziwnych paczek, grzebanie w konfiguracji programów nawet przy zachowaniu staranności prędzej czy później tworzy w systemie bajzel, w efekcie którego rzeczy przestają działać lub działają w osobliwy sposób.
  • Mam tylko jedno środowisko, żeby spróbować kod z inną wersją PHP albo innym serwerem www muszę uciekać się do karkołomnych/upierdliwych konfiguracji.

Czytaj dalej „Na poły wirtualne środowisko developerskie”

Sortowanie wyników z użyciem funkcji w Solarium

To będzie zwięzły wpis dokumentujący małe zwycięstwo w pojedynku z Solarium. Zadanie polegało na dodaniu nowego sposobu sortowania produktów: w pierwszej kolejności według dostępności produktu (bez względu na ilość, tylko binarne tak/nie), w drugiej według ceny. W bazie Solr mamy tylko ilość sztuk danego produktu, należało ją więc jakoś zrzutować na wartość binarną. Na szczęście Solr umożliwia użycie funkcji do porządkowania znalezionych dokumentów, Solarium od drugiej wersji także to obsługuje. Końcowy efekt moich starań jest następujący:

$query->addSort($query->getHelper()->functionCall('min', array('stock', 1)), Solarium_Query_Select::SORT_DESC);

Dla celów dydaktycznych dodam, że najpierw próbowałem zrobić to w ten sposób:

$query->addSort($query->getHelper()->functionCall('if', array('stock', 1, 0)), Solarium_Query_Select::SORT_DESC);

jednak kończyło się to błędem:

Solr HTTP error: sort param could not be parsed as a query, and is not a field that exists in the index: if(stock,1,0) (400)

Przyczyna okazała się prozaiczna, ale odkrycie jej zajęło mi dłuższą chwilę. Otóż funkcja if dostępna jest dopiero od wersji 4.0 Solr, a my używamy 3.6.

Generowanie losowych kodów rabatowych

Wczoraj pisałem moduł obsługujący kody rabatowe w sklepie internetowym. Wedle specyfikacji kody mogą być jedno- lub wielokrotnego użytku. Te pierwsze mogą być hurtowo generowane w panelu administracyjnym. Na chłopski rozum taki jednorazowy kod powinien być:

  • losowy, żeby pomysłowi klienci nie mogli zgadywać kodów
  • w miarę krótki, żeby wpisywanie go nie było kłopotliwe
  • pozbawiony podobnych do siebie znaków takich jak O i 0, ze względów wspomnianych w poprzednim punkcie

Mój pomysł na generowanie takich kodów wygląda następująco:

public static function generateRandom()
{
  return str_pad(strtr(strtoupper(base_convert(rand(1000, 2176782335), 10, 36)), '0O', '1A'), 6, 'X');
}

Powyższa metoda generuje losowe kody złożone z 6 alfanumerycznych znaków (wielkie litery i cyfry) z pominięciem zera i litery O. Przykładowy kod: 3LFPHP. Tytułem wyjaśnienia: 2176782335 to ZZZZZZ w systemie trzydziestoszóstkowym.

Nie robiłem żadnych testów, ale przeczucie mówi mi, że takie rozwiązanie jest szybsze niż losowanie po jednym znaku z tablicy dozwolonych znaków.

Kody powinny być także unikalne, a powyższa metoda tego nie zapewnia. Gdyby tych kodów trzeba było generować tysiące na minutę pewnie pokusiłbym się o jakiś bardziej wyszukany algorytm, np. w jednej transakcji wygenerować tablicę z liczbą kodów ciut większą niż zadana, jednym zapytaniem sprawdzić które z nich już występują w bazie, odfiltrować je z tablicy, a resztę wstawić drugim zapytaniem. W tym konkretnym sklepie nie ma takiej potrzeby, więc wybrałem prostsze rozwiązanie.

public static function generateCodes(Voucher $voucher, $number)
{
  for ($i = 1; $i <= $number; $i++)
  {
    do
    {
      try
      {
        $code = new VoucherCode();
        $code->setVoucher($voucher)
          ->setCode(VoucherCode::generateRandom())
          ->save();
      }
      catch (Doctrine_Connection_Mysql_Exception $e)
      {
      }
    } while ($code->isNew())
  }
}

Kolumna code w tabeli ma założony unikalny indeks (przydaje się do wyszukiwania), więc próba wstawienia takiej samej wartości powoduje wyjątek, który łapię i generuję nowe wartości dopóki wstawianie rekordu do tablicy nie zakończy się powodzeniem.

PLAIN stupid czyli jak zostałem spamerem

Dzisiaj będzie o głupim błędzie w konfiguracji Exima, który wynikł z bezrefleksyjnego kopiowania i wklejania opublikowanych w Internecie przykładowych konfiguracji.

Ze zdziwieniem przeczytałem maila z serwerowni informującego, że z mojego serwera jest wysyłany spam. Dołączony w załączniku mail wyglądał na wysłany z mojego serwera, a w logach Exima znalazłem informację o wysłaniu inkryminowanej wiadomości, więc o pomyłce serwerowni nie było mowy.

2012-06-22 09:34:16 1ShyNj-0000KH-Cx <= harun@digit0.com H=server177288-1.santrex.net [37.59.45.10] P=esmtpa A=plain_pgsql:sales S=3593 id=73D566338398i-49A3A971B01x025-29B991E@PHRSBS
2012-06-22 09:34:18 1ShyNj-0000KH-Cx => altairhosttest@hotmail.com R=dnslookup T=remote_smtp H=mx2.hotmail.com [65.54.188.94]
2012-06-22 09:34:18 1ShyNj-0000KH-Cx Completed

Z powyższego wycinka logów wynika, że spamer z adresu 37.59.45.10 z powodzeniem autoryzował się jako użytkownik sales przy użyciu autoryzacji plain_pgsql. Stosowna regułka wyglądała tak:

plain_pgsql:
   driver = plaintext
   public_name = PLAIN
   server_condition = ${if eq{$auth3}{${lookup pgsql{SELECT password FROM exim_virtual_users WHERE login = '${quote_pgsql:$auth2}'}}}{yes}{no}}
   server_set_id = $auth2
   server_prompts = :

Powyższy mechanizm wyszukuje w bazie dane użytkownika o podanym loginie ($auth2) i uzyskane w ten sposób hasło porównuje z hasłem podanym przez użytkownika ($auth3). Gdzie tkwi problem? Otóż powyższy mechanizm działa świetnie dla użytkowników, którzy istnieją w bazie. Jeśli jednak sprytny spamer poda nieistniejącego użytkownika i puste hasło to autoryzacja przebiega pomyślnie! Dzieje się tak ponieważ dla nieistniejącego użytkownika zwracane jest puste hasło, a puste hasło = puste hasło.

Dokładnie ten przypadek został opisany w dokumentacji Exima, więc sobie ku pamięci i wszystkim zainteresowanym powtarzam: RTFM!

Dla porządku podaję jeszcze poprawną wersję regułki autoryzującej:

plain_pgsql:
   driver = plaintext
   public_name = PLAIN
   server_condition = ${if and{{!eq{$auth2}{}} {!eq{$auth3}{}} {eq{$auth3}{${lookup pgsql{SELECT password FROM exim_virtual_users WHERE login = '${quote_pgsql:$auth2}'}}}}} {yes}{no}}
   server_set_id = $auth2
   server_prompts = :

Ciasteczka w mod_rewrite

Dotychczas pisząc regułki dla mod_rewrite operowałem tylko na URL-ach. Ostatnio w pracy przy okazji wdrażania nowej wersji strony musiałem przekierować adresy starych stron na nowe. Trudność polegała na tym, że w starej wersji jeden URL generował trzy wersje językowe, a język jest przechowywany w ciasteczku. Na szczęście mod_rewrite może operować na wielu elementach żądania HTTP, w tym na ciasteczkach. Po kilku minutach czytania doszedłem do takiego wyniku:

RewriteCond %{QUERY_STRING} ^event=testAccount$ [NC]
RewriteCond %{HTTP_COOKIE} LANG=fr [OR]
RewriteCond %{HTTP_COOKIE} !^.*LANG.*$
RewriteRule ^/5/index\.cfm$ /5/tester-gratuitement? [NC,R=301,L]

RewriteCond %{QUERY_STRING} ^event=testAccount$ [NC]
RewriteCond %{HTTP_COOKIE} LANG=nl
RewriteRule ^/5/index\.cfm$ /5/test-gratis? [NC,R=301,L]

RewriteCond %{QUERY_STRING} ^event=testAccount$ [NC]
RewriteCond %{HTTP_COOKIE} LANG=en
RewriteRule ^/5/index\.cfm$ /5/test-for-free? [NC,R=301,L]

Pierwszy zestaw dotyczy francuskiej a zarazem domyślnej wersji strony. Istotne są druga i trzecia instrukcja RewriteCond, które odpowiednio sprawdzają czy ciasteczko LANG ma wartość fr lub czy w ogóle nie jest ustawione. Następująca po nich instrukcja RewriteRule przekierowuje do nowej francuskiej strony. Tu warto dodać, że mod_rewrite operuje na ciasteczkach w takiej postaci w jakiej są przesyłane w nagłówku HTTP Cookie czyli napisu rozdzielonego średnikami.

Hurtowe acz selektywne skalowanie zdjęć

Hurtowe skalowanie zdjęć z konsoli to pikuś – convert albo mogrify z pakietu ImageMagick nadaje się do tego bardzo dobrze. Dzisiaj musiałem sobie poradzić z nieco bardziej skomplikowanym przypadkiem. Otóż w drzewiastej strukturze katalogów miałem kilka tysięcy zdjęć, a w każdym z podkatalogów znajdowały się kopie danego zdjęcia w kilku rozmiarach. Przykład:

katalog/12/345.jpg
katalog/12/345d.jpg
katalog/12/345m.jpg
katalog/12/345s.jpg
katalog/12/346.jpg
katalog/12/346d.jpg
katalog/12/346m.jpg
katalog/12/346s.jpg

Utrudnienie polegało na tym, że chciałem zmienić rozmiar tylko zdjęć z przyrostkiem „m” (małe) wykorzystując do tego zdjęcia bez przyrostka (oryginały), tzn. dla uzyskania jak najlepszej jakości zmniejszyć oryginały.

O ile wyłuskanie oryginałów przez find nie sprawiło mi problemów, o tyle zapisanie pomniejszonej kopii pliku pod nazwą z przyrostkiem nie było dla mnie takie oczywiste. Po kilku minutach lektury podręcznika polecenia convert udało mi się osiągnąć cel dzięki takiemu poleceniu:

find katalog/ze/zdjeciami/ -type f -regex '.*/[0-9]+.jpg' -exec convert {} -resize 640x640 -set filename:f '%d/%[base]m.%e' '%[filename:f]' ;

Zapisuję je przyszły użytek mój i potomnych.

Wygodniejsze logowanie przez SSH

Logowanie przez SSH może być upierdliwe, zwłaszcza jeśli serwery są dostępne tylko przez IP lub przez długą domenę, a SSH działa na niestandardowym porcie. Mam pod swoją opieką kilka takich serwerów i spamiętanie adresów i portów nie bardzo mi szło. Swego czasu ułatwiałem sobie życie przez dodawanie adresów do pliku /etc/hosts, ale to niezbyt sensowne rozwiązanie. Jest lepsze, które odkryłem przy lekturze podręcznika ssh.

W pliku ~/.ssh/config można zdefiniować sobie parametry dostępu do poszczególnych hostów. Wygląda to tak:

Host challenger
  HostName challenger.femina.com.pl

Host bigtruck
  HostName s74.vdl.pl
  Port 59184
  User bigtruck

Dzięki temu żeby zalogować się na serwer bigtruck zamiast wpisywać:

ssh -p 59184 bigtruck@s74.vdl.pl

wystarczy:

ssh bigtruck

Dodatkową zaletą jest to, że na serwery wpisane w konfigurację ssh działa automatyczne uzupełnianie, więc w powyższym przykładzie wystarczy wklepać tylko pierwsze litery nazwy, a potem tab.

Pozostaje kwestia haseł. Temat autoryzacji przez klucze jest opisany w różnych HOWTO milion razy, ale w większości wypadków przepis zaleca ręczne skopiowanie pliku w odpowiednie miejsce na docelowym serwerze. Można prościej:

ssh-copy-id bigtruck

Dzięki tym dwóm zabiegom logowanie przez SSH jest szybkie jak mrugnięcie okiem i proste jak konstrukcja cepa.

/var/lock i /var/run w RAM-ie na Debianie

Na co dzień mam do czynienia z Debianem, Gentoo i Ubuntu. W tych dwóch ostatnich systemach katalogi /var/lock i /var/run zamontowane są na ramdysku. Ma to sporo sensu ponieważ do tych katalogów dość często zapisywane są małe pliczki, a dane są ulotne (po resecie i tak tracą znaczenie). Dzięki przeniesieniu tych danych do RAM-u oszczędzamy mechanikę dysku, miejsce, a i pewnie trochę przyspieszamy działanie systemu.

Debian prezentuje bardziej konserwatywne podejście, wspomniane katalogi są tworzone w głównym katalogu. Na szczęście przeniesienie ich do RAM-u sprowadza się do zmiany dwóch linijek w pliku /etc/default/rcS:

TMPTIME=0
SULOGIN=no
DELAYLOGIN=no
UTC=yes
VERBOSE=no
FSCKFIX=no
RAMRUN=yes
RAMLOCK=yes

Zmienione linijki pogrubiłem. Po wykonaniu tych zmian trzeba wykonać reset i voila:

System plików         rozm. użyte dost. %uż. zamont. na
/dev/mapper/gt500-root
                      7,4G  1,6G  5,5G  23% /
tmpfs                 999M     0  999M   0% /lib/init/rw
varrun                999M  420K  998M   1% /var/run
varlock               999M     0  999M   0% /var/lock
udev                  994M  160K  993M   1% /dev
tmpfs                 999M     0  999M   0% /dev/shm
/dev/sda1              89M   16M   69M  19% /boot
/dev/mapper/gt500-home
                      104G   17G   88G  16% /home

Jak wyczytałem w dokumentacji Debiana, niektóre starsze programy źle działają jeśli /var/lock i /var/run nie są obecne na dysku. Na moim serwerku mam sporo softu (m.in. Apache, MySQL, PostgreSQL, Exim, ProFTPd, memcached) i nie zauważyłem żadnych problemów. Dlatego zgaduję, że w większości instalacji Debiana, zarówno desktopowych jak i serwerowych, warto pokusić się o wykonanie omawianej zmiany.