Checkbox kontra NOT NULL

W encji mamy pole legal1 typu boolean, które nie może być NULL-em.

    /**
     * @var boolean
     *
     * @ORM\Column(name="legal1", type="boolean", columnDefinition="TINYINT(1)")
     */
    private $legal1;

W formularzu pole jest prezentowane jako checkbox. Niby wszystko powinno działać, a jednak jeśli wyślemy formularz bez zaznaczania checkboxa to na encji nie jest wywoływana metoda setLegal1(), a w rezultacie Doctrine próbuje zapisać NULL jako wartość pola legal1, co oczywiście kończy się wyjątkiem.

Można kombinować na samej encji (np. zwracać z getLegal1() 0 jeśli wartość to NULL), ale chyba najbardziej eleganckim rozwiązaniem będzie zastosowanie transformera, który będzie tłumaczył wartości pomiędzy encją a formularzem. Prościutki transformer realizujący takie zadanie może wyglądać tak.

namespace MDurys\CommonBundle\Lib\DataTransformer;

use Symfony\Component\Form\DataTransformerInterface;

class BoolTransformer implements DataTransformerInterface
{
    public function transform($input)
    {
        return boolval($input);
    }

    public function reverseTransform($input)
    {
        return intval($input);
    }
}

W formularzu należy go zastosować jako model transfomer:

    $builder
        ->add(
            $builder->create('legal1', 'checkbox')->addModelTransformer(new BoolTransformer())
            )

Jego działanie polega na tym, że podczas tworzenia formularza zostanie wywołana metoda transform(), która wartość z bazy danych (0 lub 1) rzutuje na wartość boolowską. Z kolei podczas zapisywania formularza zostanie wywołana metoda reverseTransform(), która rzutuje wartość z formularza na liczbę całkowitą. Tym sposobem z NULL-a zrobi się 0.

Dekodowanie daty urodzenia i płci z PESEL-u

Wczoraj poprawiałem walidację numeru PESEL w aktualnym projekcie. PESEL zawiera informacje o płci i dacie urodzenia osoby, do wyciągnięcia tych danych napisałem sobie taką klasę.

class Pesel
{
    public static function extractSex($pesel)
    {
        return intval(substr($pesel, 9, 1)) % 2 == 0 ? 1 : 2;
    }

    public static function extractDate($pesel)
    {
        list($year, $month, $day) = sscanf($pesel, '%02s%02s%02s');
        switch (substr($month, 0, 1)) {
            case 2:
            case 3:
                $month -= 20;
                $year += 2000;
                break;
            case 4:
            case 5:
                $month -= 40;
                $year += 2100;
            case 6:
            case 7:
                $month -= 60;
                $year += 2200;
                break;
            case 8:
            case 9:
                $month -= 80;
                $year += 1800;
                break;
            default:
                $year += 1900;
                break;
        }
        return checkdate($month, $day, $year)
            ? new \DateTime("$year/$month/$day")
            : null;
    }
}

Metoda extractSex() zwraca 1 jeśli numer PESEL należy do kobiety lub 2 jeśli do mężczyzny. Metoda extractDate() zwraca obiekt DateTime z datą urodzenia z PESEL-u.

W klasie brakuje walidacji poprawności PESEL-u, ale w moim projekcie załatwia to osobny walidator.

aptitude: Błędna suma kontrolna

Kilka dni temu aptitude po aktualizacji dostępnych pakietów powiedział mi:

W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/main/source/SourcesIndex: Błędna suma kontrolna
W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/contrib/source/SourcesIndex: Błędna suma kontrolna
W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/non-free/source/SourcesIndex: Błędna suma kontrolna
W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/main/binary-amd64/PackagesIndex: Błędna suma kontrolna
W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/contrib/binary-amd64/PackagesIndex: Błędna suma kontrolna
W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/non-free/binary-amd64/PackagesIndex: Błędna suma kontrolna
W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/main/binary-i386/PackagesIndex: Błędna suma kontrolna
W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/contrib/binary-i386/PackagesIndex: Błędna suma kontrolna
W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/non-free/binary-i386/PackagesIndex: Błędna suma kontrolna
W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/contrib/i18n/Translation-enIndex: Błędna suma kontrolna
W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/main/i18n/Translation-plIndex: Błędna suma kontrolna
W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/main/i18n/Translation-enIndex: Błędna suma kontrolna
W: Nie udało się pobrać http://ftp.pl.debian.org/debian/dists/testing/non-free/i18n/Translation-enIndex: Błędna suma kontrolna
E: Nie udało się pobrać niektórych plików indeksu, zostały one zignorowane lub użyto ich starszej wersji.
E: Nie można przebudować informacji o pakietach

Poproszony o upgrade odparł zaś, że ma milion niespełnionych zależności i jeśli chcę to mogę go uruchomić z opcją --full-resolver. Na takie dictum acerbum chwilowo skapitulowałem, wszak nie muszę codziennie mieć najświeższych pakietów. Zakładałem, że coś się skopało na debianowym serwerze i zaraz to naprawią. Jednak i dzień później i w kolejne dni aptitude twardo obstawał przy swoim. Tego już było za wiele. Trzeba było pokazać, kto tu rządzi:

aptitude update -o Acquire::Pdiffs=false

Powyższa opcja wymusza pobranie pełnych list pakietów, a nie tylko diffów. Pomogło.

Filtry strumieni w PHP

Ostatnio pracowałem nad wymianą plików w formacie CSV. PHP ma funkcje fputcsv() i fgetcsv(), które dbają o właściwe umieszczanie/dekodowanie cudzysłowów, więc zadanie jest trywialne. Jedyny problem polegał na tym, że zdalny system zapisywał i łykał pliki z kodowaniem Windows-1250 i znacznikami końca linii CRLF. Oczywiście można traktować iconvem tablice przekazywane do fputcsv() i zwracane przez fgetcsv(), przy zapisie można po każdym fputcsv() zapisywać znacznik LF, ale to rozwiązanie niezbyt eleganckie.

Na szczęście architektura PHP 5 jest elastyczna, funkcje wyjścia/wejścia operują na strumieniach, a do strumieni można dodawać filtry. Powyższy problem można rozwiążać dodając do strumienia filtr, który dokona konwersji kodowania i znaczników końca linii. Wystarczy do tego malutka klasa:

namespace MDurys\DataBundle\StreamFilter;

class CP1250CRLFFilter extends \php_user_filter
{
    public function filter($in, $out, &$consumed, $closing)
    {
        while ($bucket = stream_bucket_make_writeable($in)) {
            $bucket->data = str_replace("\n", "\r\n", iconv('UTF-8', 'CP1250', $bucket->data));
            $consumed += $bucket->datalen;
            stream_bucket_append($out, $bucket);
        }
        return PSFS_PASS_ON;
    }
}

Plik otwieramy jak zwykle:

if (null === ($fh = fopen($path, 'w'))) {
        throw new FileException($path);
}

Potem wystarczy zarejestrować filtr i dodać go do uchwytu naszego pliku:

stream_filter_register('windows', '\MDurys\DataBundle\StreamFilter\CP1250CRLFFilter');
stream_filter_append($fh, 'windows');

Od tej pory wszystko co zapiszemy do pliku zostanie skonwertowane z UTF-8 na CP1250, a znacznki końca linii zamienione na CRLF.

Edycja boot menu UEFI w Debianie

Mój służbowy laptop (HP Pavilion G6) musiał powędrować do serwisu, więc admin przełożył dysk i pamięć z mojego komputera do identycznego modelu. Na chłopski rozum wszystko powinno ruszyć bez problemu, ale niestety po takiej operacji komputer przywitał mnie komunikatem „Hard Disk – (3F0) BootDevice not found”. Oczywiście opcje UEFI były identycznie ustawione w obydwu komputerach.

Z problemem biedziłem się kilka godzin, zastanawiałem się czy to problem z szyfrowaniem dysku (LUKS), flagą boot partycji, a może identyfikatorami partycji. Problem musiał być trywialny, bo po uruchomieniu systemu z LiveCD partycje można było podmontować, a dane były na miejscu. Na wszelkie wypadek dodam, że ponowna instalacja GRUB-a nie pomogła.

Odkryłem, że jeśli wystartuję komputer z LiveCD, ale zamiast uruchamiać Linuksa wyjdę z menu GRUB-a (Escape, a potem exit) to komputer pokazuje menu UEFI, z którego mogę wybrać urządzenie i plik uruchomieniowy. Na moim dysku komputer widział plik EFI/debian/grubx64.efi, po wybraniu którego system startował normalnie.

Z pomocą kolegi trafiłem na trop programu efibootmgr, który służy do edycji menu uruchomieniowego UEFI. Po kilku próbach doszedłem do polecenia, które postawiło mój system na nogi:

efibootmgr --create --label "Debian" --loader \\EFI\\debian\\grubx64.efi

Powyższe polecenia zakłada, że partycja EFI jest na /dev/sda i jest podmontowana jako /boot/efi. Jeżeli jest inaczej należy je wskazać odpowiednio przez --disk i --part. Użycie backslashy w parametrze --loader jest istotne.

Efekt działania można sprawdzić poleceniem efibootmgr --verbose. U mnie wygląda to tak:

BootCurrent: 0001
Timeout: 0 seconds
BootOrder: 3001,2001,2002,2003
Boot0001* Debian	HD(1,800,f3800,b865a07b-fdff-428d-9443-9fb85820b4f6)File(\EFI\debian\grubx64.efi)
Boot2001* USB Drive (UEFI)	RC
Boot2002* Internal CD/DVD ROM Drive (UEFI)	RC
Boot3001* Internal Hard Disk	RC

SSHFS – problemy i rozwiązania

Trochę wody w Wiśle upłynęło odkąd ostatnio używałem SSHFS, więc kiedy przyszło mi podmontować udział z serwera przez ten protokół nie obyło się bez problemów.

Instalacja

O ile wcześniej tego nie zrobiliśmy to SSHFS trzeba zainstalować:

aptitude install sshfs

Montowanie:

sshfs mdurys@10.0.0.2:/home/mdurys/projekt /home/joe/Projekty/projekt

Moja nazwa użytkownika na zdalnej maszynie jest inna niż na lokalnej, więc musiałem ją podać w ścieżce.

Tu może pojawić się pierwszy problem:

failed to open /dev/fuse: Permission denied

Rozwiązanie jest proste:

usermod -aG fuse joe

Następnie trzeba się przelogować, lub, jeśli ktoś lub starą windowsową szkołę, ponownie uruchomić komputer.

Dla pewności można sprawdzić czy użytkownik trafił do grupy fuse.

groups joe
joe : joe cdrom floppy audio dip video plugdev fuse scanner netdev bluetooth

Problem z właścicielem plików

Jeśli nazwy i identyfikatory użytkowników są różne na obu maszynach to wystąpią problemy z dostępem do plików ponieważ SSHFS w lokalnym systemie pokaże takiego właściciela jak na zdalnym systemie.

-rw-r--r-- 1 1001 1001   141 sty  9 11:13 AppCache.php
-rw-r--r-- 1 1001 1001  2395 sty  9 11:13 AppKernel.php
-rw-r--r-- 1 1001 1001   267 sty  9 11:13 autoload.php
-rwxr-xr-x 1 1001 1001   865 sty  9 11:13 console

Można temu zaradzić używając opcji idmap=user

sshfs -o idmap=user mdurys@10.0.0.2:/home/mdurys/projekt /home/joe/Projekty/projekt

Dzięki temu użytkownik dokonujący montowania zostanie zmapowany na użytkownika, na którego się łączymy.

-rw-r--r-- 1 joe 1001   141 sty  9 11:13 AppCache.php
-rw-r--r-- 1 joe 1001  2395 sty  9 11:13 AppKernel.php
-rw-r--r-- 1 joe 1001   267 sty  9 11:13 autoload.php
-rwxr-xr-x 1 joe 1001   865 sty  9 11:13 console

Problem z grupą plików

Jak widać na liście plików z poprzedniego akapitu, pliki mają poprawnego właściciela, ale grupa ciągle nie jest zmapowana. To może powodować problemy z dostępem, ale i na to jest remedium:

sshfs -o idmap=user -o uid=1000 -o gid=1000 mdurys@10.0.0.2:/home/mdurys/projekt /home/joe/Projekty/projekt

Po takim zabiegu również grupy są poprawne:

-rw-r--r-- 1 joe joe   141 sty  9 11:13 AppCache.php
-rw-r--r-- 1 joe joe  2395 sty  9 11:13 AppKernel.php
-rw-r--r-- 1 joe joe   267 sty  9 11:13 autoload.php
-rwxr-xr-x 1 joe joe   865 sty  9 11:13 console

SSHFS w /etc/fstab

Żeby nie wpisywać wszystkiego z palca z każdym razem można definicję zasobu wrzucić do /etc/fstab. W takim wypadku trzeba podać ścieżkę do klucza, który ma zostać użyty do autoryzacji.

sshfs#mdurys@10.0.0.2:/home/mdurys/projekt /home/joe/Projekty/projekt fuse defaults,IdentityFile=/home/joe/.ssh/id_rsa,uid=1000,gid=1000 0 0

Jeśli zasób możne być niedostępny podczas uruchamiania komputera to nie warto montować go automatycznie, lepiej umożliwić montowanie go przez użytkowników na żądanie.

sshfs#mdurys@10.0.0.2:/home/mdurys/projekt /home/joe/Projekty/projekt fuse defaults,noauto,user,uid=1000,gid=1000 0 0

bash: brak dostępu

Ostatni problem, na który się nartknąłem był związany z uruchamianiem skryptów:

app/console
bash: app/console: Brak dostępu

Dzieje się tak ponieważ parametr user w opcjach montowania implikuje noexec, co można łatwo sprawdzić poleceniem

mount -l

Oczywiście zamiast app/console można posługiwać się php app/console, ale jeśli chcemy korzystać z dobrodziejstw autouzupełniania poleceń albo po prostu oszczędzać klawiaturę to należy dodać parametr exec:

sshfs#mdurys@10.0.0.2:/home/mdurys/projekt /home/joe/Projekty/projekt fuse defaults,noauto,user,exec,uid=1000,gid=1000 0 0

Lektura dodatkowa

Dlaczego drukowanie dwustronne przestało działać?

Drukowanie dwustronne to obok majtek z gumką jeden z najprzydatniejszych wynalazków naszej ery. Zrozumiecie zatem moje zdumienie i zaniepokojenie, kiedy to po świeżej instalacji systemu stosowna opcja zniknęła z okienka ustawień wydruku.

Przyczyna problemu tkwi w wybranym sterowniku drukarki. Jak widzicie na obrazku, do mojego Lexmarka E450DN Debian proponuje trzy sterowniki. W tej chwili nie pamiętam czemu wybrałem lj5gray, jednak ten sterownik nie pozwala korzystać z dobrodziejstw druku dwustronnego. Po zmianie sterownika na Postscript znowu mogę drukować dwustronnie.

Wybór sterownika drukarki

Symfony2, Twig i SwiftMailer czyli rzecz o obrazkach w mailu

Ongiś pisanie maili w HTML-u było naruszeniem netykiety, ale obecnie zdecydowana większość odbiorców jak i nadawców woli otrzymywać maile w HTML-u. Generowanie i wysyłanie takowych w aplikacji opartej na Symfony2 jest banalnie proste dzięki Twigowi i Swift Mailerowi. Nieco mniej przyjemnie zaczyna się robić, kiedy w treści maili ma pojawić się grafika.

Możliwości są dwie i każda rodzi pewne problemy:

  1. Linkowanie do plików na zdalnym serwerze. Niestety funkcja url() w Twigu akceptuje tylko ścieżki zdefiniowane w routingu, nie można nią linkować do grafik. Z kolei znacznik image obsługiwany przez Assetica domyślnie generuje relatywne URL-e, a poza tym jest trochę nieporęczny w użyciu.
  2. Osadzenie plików w wiadomości. Tu problem natury programistycznej polega na tym, że żeby osadzić grafikę w wiadomości trzeba skorzystać z metody Swift_Message::embed(), do której nie mamy dostępu z poziomu Twiga.

Wszystkie powyższe problemy można rozwiązać tworząc rozszerzenie do Twiga.

namespace MDurys\MailBundle\Twig;

use Symfony\Component\DependencyInjection\ContainerInterface;

class MailExtension extends \Twig_Extension
{
    private $kernel;

    private $host;

    private $imageCache;

    private $messageCache;

    public function __construct(ContainerInterface $container)
    {
        $context = $container->get('router')->getContext();
        $this->host = $context->getScheme() . '://' . $context->getHost();
        $this->kernel = $container->get('kernel');
    }

    public function getFunctions()
    {
        return array(
            new \Twig_SimpleFunction('mail_embed', array($this, 'mailEmbed')),
            new \Twig_SimpleFunction('mail_url', array($this, 'mailUrl')),
        );
    }

    public function mailEmbed(\Swift_Message $message, $file)
    {
        if (!isset($this->messageCache[$message->getId()][$file]))
        {
            if (!isset($this->imageCache[$file]))
            {
                $this->imageCache[$file] = \Swift_Image::fromPath($this->kernel->locateResource($file));
            }
            $this->messageCache[$message->getId()][$file] = $message->embed($this->imageCache[$file]);
        }
        return $this->messageCache[$message->getId()][$file];
    }

    public function mailUrl($file)
    {
        return $this->host . $file;
    }

    public function getName()
    {
        return 'mdurys_mail_extension';
    }
}

Rozszerzenie udostępnia dwie funkcje Twiga: mail_embed() i mail_url(), które odpowiednio umożliwiają osadzenie obrazka w wiadomości i wygenerowanie abosultnego URL-a do zasobu. Osadzane obrazki są cacheowane, więc jeśli dany obrazek jest wykorzystywany kilka razy w danej wiadomości to zostanie osadzony tylko raz, a jeśli obrazek zostanie osadzony w kilku wiadomościach to proces konwersji na obiekt Swift_Image zostanie wykonany tylko raz.

Aby móc z tego rozszerzenia korzystać należy je uaktywnić w services.yml bundla. Ważne jest, żeby otagować usługę jako „twig.extension”.

services:
  mdurys.twig.mail_extension:
    class: MDurys\MailBundle\Twig\MailExtension
    arguments: [ "@service_container" ]
    tags:
      - { name: twig.extension }

Jeśli chcemy osadzić obrazek w wiadomości to podczas renderowania szablonu trzeba przekazać obiekt wiadomości.

$message = \Swift_Message::newInstance();
$htmlPart = $this->renderView(
    'MailBundle:Test:email.html.twig',
    array('message' => $message)
    );
$message
    ->setFrom('mail@example.com')
    ->setTo('test@fexample.com')
    ->setSubject('Mail z obrazkami')
    ->setBody($htmlPart, 'text/html');
$this->get('mailer')->send($message);

W szablonie Twiga wystarczy wywołać funkcję mail_embed() przekazując jej jako parametry obiekt wiadomości i lokalizację pliku.

<img src="{{ mail_embed(message, '@MDurysMailBundle/Resources/public/images/smiley.gif') }}" width="16" height="16" alt=":-)" />

Linkowanie do zewnętrznych zasobów jest prostsze i sprowadza się do wywołania funkcji mail_url() w szablonie:

<img src="{{ mail_url('/bundles/mdurysmail/images/big_logo.jpg') }}" width="600" height="240" alt="Big Logo" />

Klucz obcy jako klucz główny w Doctrine2

Jest to jak najbardziej możliwe i bardzo ładnie opisane w dokumentacji Doctrine2. Przykład poniżej.

namespace Acme\Bundle\ShopBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="customer_preferences")
 */
class CustomerPreferences
{
    /**
     * @ORM\Id
     * @ORM\OneToOne(targetEntity="Acme\Bundle\ShopBundle\Entity\Customer")
     * @ORM\JoinColumn(name="customer_id", referencedColumnName="id")
     */
    private $customer;

    // ...
}

W tabeli wygenerowanej z tej encji pole customer_id będzie zarówno kluczem obcym wskazującym na pole id w tabeli customer jak i kluczem głównym.

Z jednej strony wydaje się to oczywiste i intuicyjne, ale parę razy przekonałem się, że rozwiązania w Doctrine2 nie zawsze są oczywiste i intuicyjne. Na szczęście akurat w tym wypadku są.

Antyspam w O2.pl lubi linki

Tworzenie i wysyłanie maili z aplikacji tak, by nie wpadały do spamu to spory kawałek wiedzy. Być może kiedyś opiszę moje doświadczenia w tym temacie, dzisiaj skupię się na jednej ciekawostce związanej z omijaniem filtra antyspamowego w poczcie O2.pl.

Problem na pierwszy rzut oka nietypowy: niektóre z maili wysyłanych na konta O2.pl dochodzą, inne zaś wpadają do spamu. Oczywiście maile wysyłane w z jednej aplikacji, wysyłane w ten sam sposób i z grubsza w tym samym czasie, jedyną różnicą jest treść. Niestety nagłówki w O2.pl nie zawierają informacji co dokładnie powoduje zakwalifikowanie wiadomości jako spam. Na wyczucie próbowałem parafrazować i przeredagować treść problematycznych maili, ale to nic nie dało. W końcu zauważyłem, że odrzucane maile nie zawierają żadnych linków podczas gdy te poprawnie dostarczane przynajmniej jeden. Na próbę dodałem byle jaki link i jak za dotknięciem magicznej różdżki maile zaczęły lądować w skrzynce „Odebrane”.

Na moje wyczucie powinno być odwrotnie, to maile z linkami są bardziej podejrzane. Zgaduję, że może filtr antyspamowy O2.pl zakłąda, że wiadomość powinna zawierać link do wypisania się z listy, ale to byłoby dość dziwne założenie. Tak czy owak dodanie linka pomaga.