Prosta księga gości w PHP z wykorzystaniem PDO i SQLite

Artykuł dodany: 19 stycznia 2016. Ostatnia modyfikacja: 08 lutego 2017.

Stopień trudności (1 - dla początkujących, 5 - dla ekspertów): 1

Tematem dzisiejszego artykułu będzie bardzo prosta księga gości, napisana z wykorzystaniem języka PHP i bazy danych SQLite. Poznamy czym są wyjątki, w jaki sposób pobierać i wstawiać dane za pomocą PDO, jak zabezpieczać dane pochodzące od użytkownika przy użyciu funkcji filtrujących. Założeniem będzie, aby księgę można osadzić na dowolnej stronie internetowej, dołączając jej jeden plik. Struktura plików dla całego projektu będzie miała postać:

|-guestbook.db   - plik bazy danych
|-guestbook.php  - logika, operacje na bazie
|-guestbook.html - formularz dodawania nowych wpisów

Zakładam Czytelniku że znasz choćby podstawy języka SQL oraz wiesz czym jest HTML. Wszystkie dane zapisywane będą jako UTF-8. Zaczynajmy.

Przygotowanie bazy danych SQLite

Większość przykładów księgi gości znalezionych w sieci operuje na zwykłych plikach tekstowych. Chociaż są one łatwe w odczycie i stosunkowo proste do wykonania, mogą sprawić kilka problemów np. przy jednoczesnym dostępie do tego samego pliku może wystąpić blokada, wpisy mogą zostać nadpisane. Równocześnie, nie ma praktycznie hostingu, który nie oferowałby dostępu do bazy danych SQLite. Nie wymaga ona żadnej zaawansowanej konfiguracji, jest bardzo szybka i co najważniejsze, oferuje standard języka SQL do przeprowadzania operacji. Bazę możemy utworzyć w dowolnym programie a nawet wtyczce do Firefoksa.

Nasza baza danych będzie wymagała kilku pól, które najlepiej pokazać na przykładzie struktury danych:

CREATE TABLE "guestbook"(
	"id" Integer NOT NULL PRIMARY KEY AUTOINCREMENT,
	"add_date" DateTime NOT NULL DEFAULT (datetime('now','localtime')),
	"username" Text NOT NULL,
	"email" Text NOT NULL,
	"comment" Text NOT NULL
);

Kluczem głównym jest kolumna `id`. Dzięki opcji AUTOINCREMENT każdy nowy rekord, wstawiony za pomocą polecenia INSERT, zwiększy nam licznik o 1. Następnie mamy `add_date` – pole typu DateTime czyli przechowujące dane w postaci np.: 2016-01-19 19:29:03. Konstrukcja DEFAULT datetime(‘now’,‘localtime’) powoduje, iż wstawienie nowego rekordu automatycznie uzupełni pole o bieżącą datę. Będziemy mogli zatem pominąć je przy dodawaniu nowych rekordów. Pozostałe pola – `username`, `email`, `comment` – są typu tekstowego. Wszystkie pola są wymagane – nie mogą zawierać w sobie wartości NULL.
Gotową bazę danych będzie można pobrać w plikach na końcu artykułu.

Przygotowanie formularza HTML

Gdy mamy już gotową strukturę bazy danych możemy przejść do stworzenia formularza. Będzie on zawarty w oddzielnym pliku HTML, wczytywanym przez główny skrypt PHP.

guestbook.html

<form action="" method="POST" class="gb_form">
    <fieldset>
        <legend>Nowy wpis w księdze gości</legend>
        <div>
            <label for="gb_username">Nick / Nazwa użytkownika</label>
            <input id="gb_username" type="text" name="gb_username" required />
        </div>
        <div>
            <label for="gb_email">Adres e-mail</label>
            <input id="gb_email" type="email" name="gb_email" required />
        </div>
        <div>
            <label for="gb_comment">Komentarz</label>
            <textarea name="gb_comment" id="gb_comment" cols="30" rows="10" required></textarea>
        </div>
    </fieldset>
    <input type="submit" name="gb_save" value="Zapisz komentarz" />
</form>

Chcemy aby istniała możliwość osadzenia formularza na dowolnej stronie internetowej, stąd dla rozróżnienia, wszystkie elementy oznaczymy prefiksem `gb_`. Zgodnie z wcześniejszymi założeniami, pola `email`, `username` oraz `comment` są wymagane, dlatego będą posiadały atrybut `required`. Nie zwolni to nas oczywiście ze sprawdzenia po stronie PHP czy faktycznie wartość danego pola została przesłana. W artykule nie będę zajmował się stylowaniem za pomocą CSS – zostawiam to już Wam.

Wyświetlenie formularza na stronie

We wcześniejszym kroku przeniosłem formularz do zewnętrznego pliku z kilku powodów. Po pierwsze, nie ma większego sensu mieszać kodu HTML z PHP. Można oczywiście osadzić go za pomocą echo czy heredoc ale spowoduje to wolniejszą pracę parsera oraz problemy z łączeniem ciągów. Po drugie, dobry edytor umożliwi wygenerowanie całej struktury formularza prawie automatycznie (np. dzięki emmet.io). Podpowie też składnię HTML. I w końcu po trzecie, jeżeli korzystamy na stronie z systemów szablonów będzie można łatwo przepisać kod tak, aby był z nim zgodny. Biorąc to pod uwagę wyświetlenie formularza sprowadzi się do jego dołączenia poprzez require:

guestbook.php

<?php

require 'guestbook.html';

Dodawanie nowych wpisów

Pora zająć się logiką naszej mini aplikacji. Wiadomo już że podzielona będzie na dodawanie nowych wpisów oraz listowanie istniejących w bazie. Dodawanie powinno być wykonane dla metody POST, wyświetlanie natomiast dla GET. Jak w PHP odczytać te wartości? Język PHP zawiera kilka predefiniowanych zmiennych globalnych, dostępnych zazwyczaj w postaci tablicy. Interesująca jest zmienna $_SERVER w której znajdziemy bardzo użyteczne dane dotyczące informacji środowiskowych. Polecam jej wyświetlenie za pomocą polecenia:

var_dump($_SERVER);

Wśród wyświetlonych kluczy znajduje się indeks `REQUEST_METHOD` i to dzięki niemu wiemy, jaka metoda żądania została wykorzystana do udzielenia dostępu do strony. Możemy teraz zmodyfikować nasz plik `guestbook.php` o następujące linie:

<?php
// odczytujemy metodę dostępu do strony
$method = $_SERVER['REQUEST_METHOD'];

// POST
if ('POST' === $method && isset($_POST['gb_save'])) {
    // kod wpisujemy tutaj
} elseif ('GET' === $method) { // GET
}
require 'guestbook.html';

Dodatkowo dla metody POST sprawdzamy, czy ustawiona jest zmienna $_POST[‘gb_save’] czyli przycisk `submit` formularza. Ma to na celu jednoznaczne zidentyfikowanie formularza gdyby było ich kilka na naszej stronie docelowej, a każdy wysyłałby dane metodą POST. Możemy też skonfigurować od razu ciąg DSN dla sterownika PDO. Nie jest on szczególnie skomplikowany dla bazy SQLite – zawiera tylko nazwę pliku:

<?php
// DSN dla PDO
$dsn = 'sqlite:guestbook.db';
// odczytujemy metodę dostępu do strony
$method = $_SERVER['REQUEST_METHOD'];
...

W artykule dotyczącym PDO opisałem podstawowe założenia oraz polecenia. Zalecam lekturę jeżeli nie miałeś Czytelniku wcześniej z nim kontaktu. Pamiętaj aby do łączenia z bazą nie używać funkcji mysql_*() gdyż straciły już jakiekolwiek wsparcie a w PHP7 zostały całkowicie usunięte. Co zmienia się w stosunku do baz takich jak MySQL czy PostgreSQL to polecenie ustawiające metodę porównywania znaków. Zamiast SET NAMES, SQLite wykorzystuje PRAGMA:

$dbh = new PDO($dsn);
$dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$dbh->exec('PRAGMA encoding = "UTF-8";');

Linia setAttribute ustawia, że w przypadku wystąpienia błędu ma zostać rzucony wyjątek. W największym uproszczeniu wyjątki to zaawansowane, hierarchiczne struktury if/else. Możemy je zagnieżdżać wielokrotnie w sobie. Wyjątki są określonego typu – dla PDO jest to PDOException. Oznacza to że blok catch może złapać ten konkretny wyjątek a resztę pominąć. Na górze struktury wyjątków znajduje się `Exception` po którym inne wyjątki dziedziczą. Polecam przestudiowanie dokumentacji gdyż wyjątki są jedną z podstawowych struktur dobrze napisanej aplikacji.

Kolejną kwestią jest filtrowanie danych. Należy pamiętać aby zawsze bezwzględnie zabezpieczać dane pochodzące od użytkownika. Wszystkie dane wprowadzane na stronie, nagłówki, ciasteczka czy sesje można złamać i wstrzyknąć niebezpieczny kod. Dobrze stosowane PDO, powinno chronić przed podstawowymi atakami SQL Injection. Dobrze, oznacza przygotowywanie zapytań przez metodę prepare() oraz bindowanie parametrów. Inne metody jak `query()` czy `exec()` nie chronią zapytań. Na tym etapie należy sobie też postawić pytanie, czy dane z zewnątrz powinny być przefiltrowane przed umieszczeniem w bazie (dotyczy np. komentarza i możliwych tagów HTML), czy przechowywane w formie nienaruszonej. Zaawansowani programiści wybiorą zapewne opcję drugą – dzięki temu będą mieć zawsze komentarz w formie oryginalnej. My jednak, ze względu na możliwość wykonania ataku XSS przy wyświetlaniu danych, postawimy na usunięcie niebezpiecznych elementów przed ich dodaniem do bazy. Można to zrobić np. wyrażeniami regularnymi, ale wygodniej będzie skorzystać z wbudowanych funkcji filter_var(), filter_input_array() i pochodnych.

if ('POST' === $method && isset($_POST['gb_save'])) {
    $args = array(
        // Sprawdzamy poprawność adresu email za pomocą wbudowanego walidatora
        'gb_email' => array(
            'filter' => FILTER_VALIDATE_EMAIL
        ),
        // nazwa komentującego musi zawierać minimum 10 znaków
        // po sprawdzeniu długości zwracamy wyczyszczone dane
        'gb_username' => array(
            'filter'  => FILTER_CALLBACK,
            'options' => function($username) {
                return (mb_strlen($username) < 5) ? false : filter_var($username, FILTER_SANITIZE_STRING);
            }
        ),
        'gb_comment' => array(
            'filter'  => FILTER_CALLBACK,
            'options' => function($comment) {
                return (mb_strlen($comment) < 10) ? false : filter_var($comment, FILTER_SANITIZE_STRING);
            }
        )
    );
    $filter = filter_input_array(INPUT_POST, $args);
    var_dump($filter);
}

Filter_input_array w naszym przykładzie, pobiera wszystkie dane z tablicy POST i aplikuje do niej filtry. Adres email sprawdzany jest za pomocą wbudowanego walidatora, do dwóch pozostałych pól aplikujemy nasz własny filtr (FILTER_CALLBACK) będący funkcją anonimową. Nazwa użytkownika musi mieć odpowiednio minimum 5 znaków, komentarz minimum 10. Po poprawnej walidacji dane czyszczone są przy użyciu FILTER_SANITIZE_STRING, który działa m.in. jak funkcja htmlspecialchars(). W przypadku niepoprawnej walidacji zwracane jest `false`. Fakt ten możemy w bardzo ciekawy sposób wykorzystać. Aplikując do zmiennej $filter funkcję array_filter() bez parametrów, otrzymamy tablicę samych poprawnych wartości.

var_dump(array_filter($filter));

Zadanie: Wyświetlić błędy dla niepoprawnych pól, wstrzymać dalsze wykonywanie skryptu (dodawanie danych do bazy) jeżeli dane są nieprawidłowe.

Pokażę teraz jak wstawić dane do bazy, ale przerwanie skryptu zostawiam jako zadanie domowe. Piszcie w komentarzach jak sobie z nim poradziliście.

Zapytanie to prosty INSERT wstawiający wartości pobrane z przefiltrowanej tablicy:

$sql = 'INSERT INTO guestbook(email, username, comment) VALUES(:email, :username, :comment)';
$sth = $dbh->prepare($sql);

Jak wspomniałem wcześniej, data dodania komentarza oraz id komentarza to wartości generowane automatycznie, zatem pomijamy je na liście pól do wstawienia. Pozostałe wartości zostały wcześniej odfiltrowane i sprawdzone. Dostępne są pod zmienną $filter:

$sth->bindParam(':email', $filter['gb_email']);
$sth->bindParam(':username', $filter['gb_username']);
$sth->bindParam(':comment', $filter['gb_comment']);
$sth->execute();

Musimy też zadbać o to, aby w przypadku wystąpienia błędu w operacjach na bazie danych, został on wypisany na ekran (złapać wyjątek). Całość kodu dla metody POST prezentuje się następująco:

if ('POST' === $method && isset($_POST['gb_save'])) {
    $args = array(
        'gb_email' => array(
            'filter' => FILTER_VALIDATE_EMAIL
        ),
        'gb_username' => array(
            'filter'  => FILTER_CALLBACK,
            'options' => function($username) {
                return (mb_strlen($username) < 5) ? false : filter_var($username, FILTER_SANITIZE_STRING);
            }
        ),
        'gb_comment' => array(
            'filter'  => FILTER_CALLBACK,
            'options' => function($comment) {
                return (mb_strlen($comment) < 10) ? false : filter_var($comment, FILTER_SANITIZE_STRING);
            }
        )
    );
    $filter = filter_input_array(INPUT_POST, $args);
    var_dump(array_filter($filter));
    // TODO: przerwać działanie skryptu jeżeli część pól została niepoprawnie wypełniona

    try {
        $dbh = new PDO($dsn);
        $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $dbh->exec('PRAGMA encoding = "UTF-8";');

        $sql = 'INSERT INTO guestbook(email, username, comment) VALUES(:email, :username, :comment)';
        $sth = $dbh->prepare($sql);
        $sth->bindParam(':email', $filter['gb_email']);
        $sth->bindParam(':username', $filter['gb_username']);
        $sth->bindParam(':comment', $filter['gb_comment']);
        $sth->execute();
    } catch (PDOException $e) {
        echo 'Klasa PDO zwróciła wyjątek: ' . $e->getMessage();
    }
}

Wyświetlanie wpisów z księgi gości

Samo wyświetlenie zapisanych wcześniej wpisów jest bardzo proste. Polega na pobraniu danych z bazy i wygenerowaniu tabeli (lub innej odpowiedniej struktury HTML).

elseif ('GET' === $method) {
    try {
        $dbh = new PDO($dsn);
        $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $dbh->exec('PRAGMA encoding = "UTF-8";');

        // Pobieramy wszystkie wpisy
        $sql = 'SELECT * FROM guestbook';
        $result = $dbh->query($sql);
        if (false === $result) {
            echo 'Brak wpisów w księdze gości';
        } else {
            echo '<table class="gb_table">';
            echo '<thead><tr><th>E-mail</th><th>Nazwa użytkownika</th><th>Komentarz</th></tr></thead><tbody>';
            foreach ($result as $row) {
                echo '<tr>';
                echo '<td>', $row['email'], '</td>';
                echo '<td>', $row['username'], '</td>';
                echo '<td>', nl2br($row['comment']), '</td>';
                echo '</tr>';
            }
            echo '</tbody></table>';
        }
    } catch (PDOException $e) {
        echo 'Klasa PDO zwróciła wyjątek: ' . $e->getMessage();
    }
}

Można oczywiście znowu przerzucić HTML do zewnętrznego pliku (albo wykorzystać system szablonów który idealnie nadaje się do generowania tego typu danych), jednak dla ułatwienia postanowiłem generować kod “w locie”. W przypadku komentarza, zamieniamy jeszcze wszystkie znaki nowej linii na element `br`. Metoda query() zwraca `false` w przypadku niepowodzenia, co wykorzystaliśmy do sprawdzenia czy jakieś wpisy zostały pobrane. Dbamy też o to, by przechwycić wszystkie możliwe wyjątki. Widać że część kodu odpowiedzialnego za połączenie z bazą jest powtórzona. Można go oczywiście przenieść wyżej i jest to drugie zadanie dla Was. Ponieważ i tak potrzebujemy w obu przypadkach nawiązać połączenie, nie musimy się martwić o “lazy loading”.

Podsumowanie

Praca na bazie SQLite z wykorzystaniem PDO jest naprawdę przyjemna. W wielu miejscach może z powodzeniem zastąpić zwykłe pliki tekstowe. Aż szkoda że tak rzadko mniej doświadczeni użytkownicy z niej korzystają. Filtrowanie danych za pomocą wbudowanych w PHP funkcji nie jest jeszcze doskonałe, ale w przypadku mniej zaawansowanego kodu sprawdza się wyśmienicie. Myślę że kod przedstawiony w artykule można z powodzeniem wykorzystać do w pełni działającego projektu. Jeżeli dołożyć do niego jeszcze edycję zamieszczonych komentarzy będzie się sprawdzał znakomicie.

Pliki przestawione w artykule można pobrać z serwisu Github.

Potrzebujesz gotowej księgi gości? Teraz możesz zakupić w pełni funkcjonalny skrypt z panelem administracyjnym.

Komentarze

  • Avatar użytkownika viking

    @Dawid: Musisz się nauczyć debugować swój kod. Po pierwsze: włącz pełne raportowanie błędów za pomocą error_reporting. Po drugie, jeśli masz rzucony wyjątek wyświetl go. Opis “mam problem z wyświetlaniem” nic kompletnie nie mówi.

  • Cześć, mały problem przy wyświetlaniu, starałem się też ręcznie dodać tabele w bazie danych, ale problem istnieje dalej. Jakieś rozwiązanie?

  • Avatar użytkownika viking

    @Jaras: Napisz jakie to konkretnie błędy bo inaczej ciężko będzie pomóc. U mnie działa.

  • Przy tworzeniu tabeli wyskakują błędy.

Dodaj komentarz

*
Nazwa zostanie wyświetlona wraz z komentarzem. Możesz też utworzyć nowe konto w serwisie, dzięki czemu uzyskasz dodatkową funkcjonalność.
*
Akceptowana jest ograniczona składnia Textile. Wszystkie tagi HTML zostaną usunięte.