e-mail:

Sklepy internetowe

Autor: Adam Major
Artykul został pierwotnie opublikowany w magazynie Software 2.0 3/2002 - "Programowanie dla Internetu" Software 2.0
www.software.com.pl
 

Uwaga: Artykuł powstał pod koniec 2001 roku od tego czasu część informacji zawartych w nim się mocno zdezaktualizowała.

W dzisiejszych czasach handel elektroniczny niekiedy przynosi firmie dużo większe zyski niż tradycyjna sprzedaż towarów i usług. Jest przy tym znacznie tańszy (odpadają koszty wynajmu i utrzymania budynków oraz zatrudnienia sprzedawców, transport) i otwarty 24h na dobę przez 365 dni w roku. Dlatego wiele firm mimo recesji jaka ogarnęła naszą gospodarkę próbuje dotrzeć do klientów uruchamiając sklepy internetowe.
Zadaniem tego artykułu jest praktyczne przedstawienie najważniejszych problemów związanych z planowaniem i projektowaniem tego typu aplikacji przy wykorzystaniu PHP 4 i serwera baz danych MySQL.
Najpierw musimy sobie postawić podstawowe pytanie czym tak naprawdę jest sklep internetowy i jakie powinien spełniać funkcje? Dobrze zaprojektowany sklep powinien przypominać w swoim działaniu placówkę handlową ze "świata realnego". Po wejściu klienta na stronę WWW aplikacja powinna kierować go w ten sposób aby mógł szybko znaleźć poszukiwany produkt lub grupę produktów, obejrzeć dostępne informacje o danym produkcie, a na końcu gdy będzie zdecydowany dokonać zakupu, pozwolić szybko, łatwo i bezpiecznie złożyć zamówienie.
Sklepy składają się więc z dwóch powiązanych ze sobą części: modułu klienckiego oraz panelu administracyjnego, za pomocą którego osoba zarządzająca może wpływać na asortyment sklepu oraz realizować zamówienia klientów.
Głównymi problemami, które stoją przed programistą, który ma zrealizować sklep internetowy są: stworzenie łatwego w użyciu, szybkiego, przyjaznego w nawigacji i bezpiecznego modułu klienckiego oraz powiązanego z nim panelu administracyjnego, pozwalającego osobie nie obeznanej ze sztuką tworzenia stron WWW prowadzić sklep.
W artykule tym poruszam najważniejsze zagadnienia z którymi przyjdzie się zmierzyć osobie tworzącej sklep internetowy tj. zaprojektowanie struktury bazy danych, koszyka oraz struktury katalogów serwisu i pliku konfiguracyjnego. Zostały także poruszone problemy związane z bezpieczeństwem.

Struktura bazy danych

Projektowanie struktury bazy danych zaczniemy od zastanowienia się jakie dane musimy przechowywać i jak najkorzystniej je pogrupować, w taki sposób aby nie dublować ich (jeśli jest to tylko możliwe) w kilku tabelach.
Możemy wyodrębnić grupy danych odpowiedzialnych za:

  1. Nawigację klienta po sklepie
  2. Przechowywanie danych o produktach
  3. Składowanie zamówień
  4. Funkcje administracyjne

Nawigacja

W większości sklepów internetowych asortyment pogrupowany jest w działy tematyczne (np. komputery, samochody), które dzielą się dodatkowo na kategorie (np. procesory, karty graficzne, monitory). My przyjmiemy także takie rozwiązanie ponieważ jest ono najbardziej rozpowszechnione i dość naturalne dla potencjalnego klienta naszego sklepu. Projektowany system nawigacji będzie umożliwiał zakładanie do maksymalnie 255 działów, zawierających po max 255 kategorii. Tego typu ograniczenia (wygodne ze względów technicznych) praktycznie nie powinny przeszkadzać w niczym osobie prowadzącej sklep.
      System nawigacji w bazie danych będziemy przechowywać w dwóch tabelach: cat będzie składować dane o działach, subcat natomiast informacje o kategoriach w danym dziale. Oprócz identyfikatora działu i kategorii zapisujemy także czy dany dział/kategoria jest aktywny, czyli czy ma być widoczny dla klienta.

W tabeli cat przechowujemy identyfikator, pole informujące czy dany dział jest aktywny (widoczny) oraz nazwę działu. W identyfikatorze id_ct nie używamy własności auto_increment, ponieważ chcemy zapobiec powstawaniu "dziur" w numeracji, na skutek kasowania i tworzenia nowych działów. Niestety wtedy o poprawną numerację będziemy musieli zadbać sami. Za aktywne działy będziemy uznawać te, który w polu ct_akt mają wpisaną cyfrę 1. Zakładamy indeksy (key) dla wszystkich pól, które będą przeszukiwane w celu wyświetlenia klientowi w której pod kategorii asortymentu się znajduje.
      Pole pid_ct tabeli subcat przechowuje identyfikator działu do którego dana kategoria należy. Id_sc to identyfikator danej kategorii o jego poprawną numerację także musimy sami zadbać w naszej aplikacji.
      Najprościej można to zrealizować sprawdzając przed dodaniem nowego działu jaki maksymalny identyfikator ma pole id_ct lub id_sc (w przypadku kategorii), a następnie wstawienie nowego wiersza z identyfikatorem powiększonym o 1. Warto także sprawdzić przedtem czy wstawiany rekord będzie miał identyfikator mniejszy od 255.
W ten sposób nie ustrzeżemy się jednak powstawania luk w przypadku skasowania innego rekordu niż ostatniego.
 
CREATE TABLE cat
(
 id_ct        tinyint unsigned not null,
 ct_act       tinyint unsigned not null,
 name_ct      char (30),
 key          ct_act (ct_act),
 key          name_ct (name_ct(6)),
 primary key (id_ct)
);

CREATE TABLE subcat
(
 id_subcat    smallint unsigned not null
              auto_increment,
 sc_act       tinyint unsigned not null,
 pid_ct       tinyint unsigned not null,
 id_sc        tinyint unsigned not null,
 name_sc      char(30),
 key          sc_act (sc_act),
 key          pid_ct (pid_ct),
 key          id_sc (id_sc),
 key          name_sc (name_sc(6)),
 primary key (id_subcat)
);
Listing 1. Polecenia SQL tworzące tablice cat i subcat.
Aby ominąć ta niedogodność można wczytać do tablicy posortowane rosnąco id_ct wszystkich działów, następnie przejrzeć tablicę w pętli i znaleźć pierwszy wiersz o nie kolejnym identyfikatorze. Jest to dość czasochłonne działanie, jednak przy 255 rekordów możemy sobie na nie pozwolić.

Tworzenie drzewka kategorii



Obok przedstawiam listing skryptu pozwalającego wyświetlić klientowi drzewko działów z wyróżnionym wybranym działem oraz rozwiniętymi jego kategoriami. Po dodaniu grafiki i stylów CSS można uzyskać taki efekt jak pokazano na rysunku 1.
Przedstawienie działów
Rysunek 1. Przedstawienie działów i kategorii w postaci drzewka.
 
<?php
{literal}
function pconnect_to_mysql()
{
 global $db, $conn;

 $conn = @mysql_pconnect($db[host], $db[user], $db[pass]);
 if (!@mysql_select_db($db[base], $conn))
    { $err_ln=__LINE__; include('db_error.php'); }
 return $conn;
}

function show_cat_tree()
{
  global $HTTP_GET_VARS;

  if ($HTTP_GET_VARS[d] <= 0 OR
    !is_numeric($HTTP_GET_VARS[d])) $HTTP_GET_VARS[d] = 1;
  $conn = pconnect_to_mysql();
  $sql = "select id_ct, name_ct from cat where ct_act=1 order
   by name_ct";
  if (!($res = @mysql_query($sql,$conn)))
     { $err_ln=__LINE__; include('db_error.php'); }
  if (mysql_num_rows($res) == 0)
     { echo 'Brak kategorii'; return 0; }

 while ($row = @mysql_fetch_row($res))
     {
      if ($row[0] == $HTTP_GET_VARS[d]) echo "- $row[1]<br />";
         else $other_cat[]= "<a href='cat.php?d=$row[0]'>+ $row[1]</a><br />";
     }
 // podkategorie
 $sql = "select id_sc, name_sc from subcat where
  pid_ct='$HTTP_GET_VARS[d]' and sc_act=1 order by name_sc";
 if (!($res = @mysql_query($sql,$conn)))
    { $err_ln=__LINE__; include('db_error.php'); }
 while ($row = @mysql_fetch_row($res))
   echo "   <a href='subcat.php?d=$HTTP_GET_VARS[d]&k=$row[0]'>$row[1]</a><br />";

 $cnt_ot = count($other_cat);
 for ($x = 0; $x < $cnt_ot; $x++) echo $other_cat[$x];
}

?>
Listing 2. Tworzenie drzewka kategorii

Przechowywanie danych o produktach

Tego typu dane najlepiej zgrupować w dwie tabele: prod i prod2. Pierwsza z nich będzie przechowywać informacje potrzebne do transakcji (tj. nazwa, cena netto, cena brutto itp.) oraz przyporządkowanie do działu i kategorii. Druga tabela będzie zawierać dłuższe opisy produktów. Podział na dwie tabele jest korzystny ponieważ znacznie zmniejszamy ilość danych, które musi przetworzyć baza dany w przypadku wyszukiwania produktu np. wg ceny.

Jednym z najważniejszych kryteriów profesjonalnego tworzenia sklepów jest zapewnienie dużej szybkości działania, dlatego w tabeli prod zapisujemy nazwę produktu w polu typu char oraz zakładamy indeksy na wszystkie pola, które będą przeszukiwane. Również ze względów wydajnościowych nie przechowujemy w bazie zdjęć produktów a jedynie informacje o nich. Pola mini* zawierają dane dotyczące miniaturki zdjęcia produktu.
Mini_t - format zdjęcia (0 - brak, 1 - GIF, 2-JPEG, 3 - PNG), ten sposób przechowywania tej informacji pozwala w razie potrzeby na dodanie kolejnego formatu grafiki np. 4-SWF. Mini_w i Mini_h to odpowiednio szerokość i wysokość obrazka. Dane te uzupełniamy przy dodawaniu nowego produktu lub jego edycji.
Rid_ct i Rid_sc zawierają identyfikator działu i kategorii.
      Aby wyświetlić wszystkie informacje o danym produkcie możemy się posłużyć następującym zapytaniem:

select * from prod, prod2 where id_pr='$HTTP_GET_VARS[id]' AND id_pr2=id_pr

 
CREATE TABLE prod
(
 id_pr       smallint unsigned not null
             auto_increment,
 pr_act	     tinyint unsigned not null,
 rid_ct	     tinyint unsigned not null,
 rid_sc	     tinyint unsigned not null,
 name        char(40) not null,
 price_n     float(4,2) not null,
 price_b     float(4,2) not null,
 vat         tinyint,
 mini_t      tinyint unsigned,
 mini_w      tinyint unsigned,
 mini_h      tinyint unsigned,
 key         pr_act (pr_act),
 key         rid_ct (rid_ct),
 key         rid_sc (rid_sc),
 key         name (name),
 key         price_n (price_n),
 key         price_b (price_b),
 primary key (id_pr)
);

CREATE TABLE prod2
(
 id_pr2      smallint unsigned not null
             auto_increment,
 big_t       tinyint unsigned,
 big_w       tinyint unsigned,
 big_h       tinyint unsigned,
 info        text,
 primary key (id_pr2)
);
Listing 3. Polecenia SQL tworzące strukturę tabel prod i prod2.

Składowanie zamówień

Jest praktycznie najistotniejszym elementem działania sklepu. W tej grupie danych musimy przechowywać wszystkie informacje konieczne do zrealizowania zamówienia oraz przydatne do generowania wszelkiego rodzaju statystyk np. dynamiki sprzedaży itp. Również tutaj najkorzystniejsze jest stworzenie dwóch tabel. W jednej będziemy przechowywać dane teleadresowe klienta w drugiej spis produktów, które zakupił.
W przypadku drugiej tabeli oprócz identyfikatora produktu, przechowujemy również jego cenę oraz wartość podatku VAT jaka była w czasie składania zamówienia. Jest to bardzo istotne ze względu na stosunkowo szybką zmianę cen czy też okresowe promocje, które mogą cechować projektowany sklep. Klient powinien płacić tyle ile wynosiła cena produktów w chwili zakupu, a nie realizacji zamówienia.

W tabeli buy oprócz danych teleadresowych przechowujemy: identyfikator zamówienia id_b, status - określa w jakim stadium realizacji jest dane zamówienie, można np. przyjąć trzy poziomy realizacji: oczekiwanie, pakowanie, wysyłka (zrealizowane). Jeśli zamierzamy udostępnić użytkownikom stronę z ich "historią zamówień" bardzo istotnym elementem jej byłaby informacja o postępach w realizacji zamówienia. Po zmianie stanu zamówienia (przez operatora sklepu) na zrealizowany, powinna zostać ustawiona data wysyłki (send_date).
      Oprócz wyżej wymienionych pól istotne są także pola: total, zawierające wartość zamówienia - można co prawda ją wyliczyć na podstawie tabeli buy2, ale jest to dość czasochłonne w przypadku generowania np. statystyk miesięcznych - i bad_post, które przechowuje informacje o liczbie błędnych prób wysłania formularza zamówieniowego. Na podstawie tej informacji operator sklepu może zdecydować czy warto wysłać produkt do klienta.
Więcej o sposobach zabezpieczenia się przed nieuczciwymi klientami w dalszej części artykułu.
W tabeli buy2 przechowywane są dane dotyczące poszczególnych produktów w zamówieniu. Id_z to identyfikator zamówienia, qt ilość sztuk produktu o identyfikatorze id_p.
 
CREATE TABLE buy
(
 id_b        smallint unsigned not null
             auto_increment,
 status      tinyint unsigned not null,
 total       float (6,2) not null,
 firm        varchar (40),
 fname       varchar (20),
 lname       varchar (30) not null,
 street      varchar (40),
 post        char (6),
 city        varchar (30),
 region      tinyint unsigned not null,
 phone       varchar (22),
 email       varchar (40),
 pesel       varchar (11),
 nip         varchar (13),
 bad_post    tinyint unsigned,
 buy_date    timestamp not null,
 send_date   timestamp not null default '0',
 host        varchar (200),
 key         status (status),
 key         total (total),
 key         firm (firm),
 key         lname (lname),
 key         region (region),
 key         buy_date (buy_date),
 key         send_date (send_date),
 primary key (id_b)
);

CREATE TABLE buy2
(
 id_b2       mediumint unsigned not null
             auto_increment,
 id_z        smallint unsigned not null,
 id_p        smallint unsigned not null,
 qt          smallint unsigned,
 price_n     float (4,2),
 price_b     float (4,2),
 vat         tinyint,
 key         id_z (id_z),
 key         id_p (id_p),
 primary key (id_b2)
);
Listing 4. Polecenia SQL tworzące tabele buy i buy2.

Funkcje administracyjne

Ta grupa danych odpowiada za logowanie się opiekunów sklepu oraz nadanie im odpowiednich uprawnień do zarządzania poszczególnymi częściami sklepu. W najprostszym przypadku, kiedy wszyscy administratorzy są sobie równi możemy zastosować następującą strukturę bazy.  
CREATE TABLE admin
(
 id_a        tinyint unsigned not null
             auto_increment,
 login       char(12) not null,
 pass        char(14) binary,
 primary key (id_a)
);
Listing 5. Polecenia SQL tworzące tabelę admin

Mamy już zaprojektowaną strukturę bazy danych teraz przychodzi czas na zaplanowanie sposobu przechowywania informacji o tym co dany klient chce kupić, czyli

Koszyk

Istnieje wiele możliwości przechowywania informacji o stanie koszyka klienta. Można zapisywać informacje po stronie klienta np. w ciastkach lub ukrytych polach formularza, jednak nie jest to bezpieczne, ponieważ użytkownik może manipulować przy tych danych. Część rozwiązań opiera się o bazę danych lub pliki tymczasowe jednak taki sposób choć o wiele bezpieczniejszy jest dość nie wygodny oraz w większości w/w rozwiązań silnie obciąża serwer. Najlepszym rozwiązaniem łączącym bezpieczeństwo i prostotę użytkowania jest zastosowanie sesji. Wszelkie informacje o stanie koszyka są przechowywane po stronie serwera, standardowo w plikach. Dane o ilości egzemplarzy produktu o danym identyfikatorze można zapisywać w tablicy dwuwymiarowej lub skorzystać z dość powszechnie znanej klasy koszyka na zakupy.
My skorzystamy właśnie z pewnej odmiany takiej klasy.

Dla części czytelników zastanawiający może być fragment od if (ini_get... oraz linia $HTTP_SESSION_VARS[cart] = $cart =...
Ze względów bezpieczeństwa powinniśmy się odwoływać do zmiennych przez tablice asocjacyjne $HTTP_*_VARS, pozwala nam to jednoznacznie określić skąd pochodzą dane.
Abyśmy mogli korzystać z globalnej przestrzeni zmiennych (wtedy wszystkie zmienne również są widoczne jako $zmienna) należy mieć włączoną w pliku php.ini opcję register_globals. PHP ewoluuje w kierunku w którym w/w opcja będzie domyślnie wyłączona. Jeśli chcemy zapewnić działanie w/w funkcji w przypadku kiedy register_globals jest włączone lub wyłączone należy posłużyć się właśnie takim trikiem, ponieważ gdy ta opcja jest włączona i koszyk nie jest zainicjowany to nie można się odwołać do metody add koszyka, którego nie ma jeszcze w sesji.
Musimy więc sprawdzić czy PHP jest skonfigurowane do tworzenia globalnej przestrzeni zmiennych jeśli tak to odwołujemy się do zmiennej globalnej, jeśli nie to odwołujemy się normalnie poprzez zmienną $HTTP_SESSION_VARS[cart].
Przynajmniej mi się nie udało tego obejść w wersji 4.0.6 inaczej [ przyp. autora po opublikowaniu tego artykułu zauważyłem bardzo proste rozwiązanie problemu. Należy sprawdzić czy istnieje zmienna sesyjna koszyk jeśli nie to zarejestrować koszyk i przekierować (wywołać ponownie) skrypt tak aby nastąpiło dodanie produktu. Jeśli natomiast jest już zarejestrowana zmienna sesyjna wystarczy wykonać metodę add i dodać produkt do koszyka], oczywiście po za skorzystaniem z funkcji
ini_set ('register_globals', '0').
      Kod z listngu 6 zapisujemy do pliku o nazwie np. dokoszyka.php, odpowiednie wywołanie tego pliku spowoduje
 
<?php

class cart
{
 var $za;
 function add ($element) { $this->za[$element]++; }
 function del ($element)
          { unset($this->za[$element]); }
 function edit ($element, $val)
          { $this->za[$element] = $val; }
 function show_cart() { return $this->za; }
 function drop_cart() { unset($this->za); }
}

function add_cart()
{
 global $HTTP_GET_VARS, $HTTP_SESSION_VARS, $cart,
  $c_total, $c_bad;

 $conn = pconnect_to_mysql();
 $sql = "select id_pr from prod where id_pr =
 '$HTTP_GET_VARS[id_p]'";
 if (!($res = @mysql_query($sql,$conn)))
   {$err_ln=__LINE__; include('db_error.php'); }
 if (@mysql_num_rows($res) == 0) return 0;

 if (!$HTTP_SESSION_VARS[cart])
  {
   session_register(cart, c_total, c_bad);
   $HTTP_SESSION_VARS[cart] = $cart = new cart;
   $HTTP_SESSION_VARS[c_total] = 0;
   $HTTP_SESSION_VARS[c_bad] = 0;
   if (ini_get ('register_globals'))
      $cart->add($HTTP_GET_VARS[id_p], 1);
      else $HTTP_SESSION_VARS[cart]->
        add($HTTP_GET_VARS[id_p], 1);
  }
  else $HTTP_SESSION_VARS[cart]->
       add($HTTP_GET_VARS[id_p], 1);
}

if ($HTTP_GET_VARS[id_p] > 0 AND
   is_numeric($HTTP_GET_VARS[id_p])) add_cart();
header('location: koszyk.php');

?>
Listing 6. Klasa cart, sposób inicjalizacji i dodania produktu do koszyka.
zainicjowanie koszyka (jeśli to konieczne), wprowadzenie nowego produktu do kosza (lub zwiększenie ilości danego produktu) oraz przekierowanie do skryptu koszyk.php. Zastosowanie pliku pośredniczącego (dokoszyka.php) dodawanie produktów do koszyka powoduje uodpornienie go na przeładowywanie strony (odśwież, reload) przez użytkownika, które mogłoby prowadzić do zwiększania się ilości danego produktu przy każdym odświeżeniu strony.

Aby pobrać z sesji informacje o identyfikatorach wszystkich produktach jakie klient ma w koszyku i skonstruować na tej podstawie zapytanie do bazy danych możemy się posłużyć następującą funkcją.
      Zmienna $zap zawiera identyfikatory produktów z koszyka w formacie id1,id2,id3... pozostałą informację o wybranych produktach uzyskamy wydając zapytanie SQL:

select id_pr, name, price_n, price_b, vat from prod where id_pr in ($zap);

W tablicy $qt_tab mamy zapisaną ilość dla poszczególnych identyfikatorów produktów.

      Po zastosowaniu w/w funkcji uzupełnionej o brakujące pobranie stosownych danych z bazy oraz formatowanie wyników zapytania możemy uzyskać taką stronę prezentującą zawartość koszyka.
 
<?php

function list_cart()
{
global $HTTP_SESSION_VARS;

 $stan = $HTTP_SESSION_VARS[cart]->show_cart();
 if (!$stan)
 {
  $HTTP_SESSION_VARS[c_total] = 0;
  info_cart_empty();
  return 0;
 }

 while (list($key, $value) = each($stan))
 {
  if ($key > 0)
  {
   $zap .= $key . ',';
   $qt_tab[$key] = $value;
  }
 }

 $zap_size = strlen($zap);
 if ($zap_size == 0)
 {
  info_cart_empty();
  return 0;
 }
if ($zap[$zap_size-1] == ',')
   $zap = substr ($zap, 0, -1);
//...
}

?>
Listing 7. Pobieranie informacji o identyfikatorach produktów w koszyku.

Strona prezentująca zawartość koszyka
Rysunek 2. Strona prezentująca zawartość koszyka.

Równie ważne co zaplanowanie bazy danych i sposobu realizacji koszyka jest

Struktura katalogów

Dobrze zaplanowana struktura katalogów pozwala na łatwe zarządzanie plikami (np. zmianę wyglądu itp.) serwisu oraz ewentualną rozbudowę jego funkcjonalności.
      W głównym katalogu serwisu utworzyłem katalogi:
admin, który zawiera wszelkie skrypty potrzebne do działania panelu administracyjnego;
img zawiera wszystkie obrazki tworzące wygląd serwisu;
tpl przeznaczony jest na szablony dokumentów itp.
Osoby odpowiedzialne za layout sklepu powinny mieć tylko dostęp do katalogów img i tpl. W katalogu img tworzone są dwa podkatalogi (mini i big) przechowujące zdjęcia produktów, miniaturki umieszczane są w mini, a zdjęcia o normalnej wielkości w big. Nazwy plików powstają na podstawie dwóch pól: identyfikatora produktu oraz typu pliku, zawartego w polach mini_t i big_t.
      Katalog conf przeznaczony jest na przechowywanie pliku(ów) konfiguracyjnych, jeśli istnieje taka możliwość to powinien zostać umieszczony poza DOCUMET_ROOT (katalogiem widocznym przez serwer WWW). Niestety wielu ISP nie daje takiej możliwości, dlatego należy stosować pliki konfiguracyjne z rozszerzeniem PHP (lub innym przetwarzanym przez PHP).
  Schemat struktury plików Rysunek 3. Schemat struktury plików.

Skoro wspomniałem już o plikach konfiguracyjnych rozwińmy, krótko to dość mało ekscytujące zagadnienie.

Plik konfiguracyjny

Warto stworzyć jeden plik konfiguracyjny dla całego sklepu, a następnie go inkludować w skryptach, które muszą skorzystać z danych w nim zawartych. Najważniejszą zasadą tworzenia takiego pliku jest to aby był on możliwie małych rozmiarów i zawierał tylko te informacje, które są przydatne w większości skryptów. Jeśli mamy klika zmiennych konfiguracyjnych używanych w zaledwie w 1 lub 2 plikach warto się pokusić o zrobienie dla nich osobnego pliku konfiguracyjnego.
Cóż więc plik konfiguracyjny powinien zawierać? Na pewno informacje potrzebne do korzystania z bazy danych, ścieżkę do katalogu img, może login i hasło do FTP'a. Osobiście preferuje używanie jako plików konfiguracyjnych zwykłych skryptów PHP zawierających dane w formacie $nazwa_zmiennej = wartosc;

Można co prawda zastosować bardziej elegancki i łatwiejszy w użytkowaniu, rodzaj konfigów, na wzór pliku php.ini. Niestety mimo udostępnienia specjalnej (nieudokumentowanej) funkcji parse_ini_file() ten sposób jest trochę wolniejszy od zwykłego inkludowania. A zmian w konfiguracji sklepu nie dokonuje się w końcu tak często.  
<?
$db["host"] = 'localhost';
$db["base"] = 'nazwa_bazy';
$db["user"] = 'uzytkownik';
$db["pass"] = 'tajne_haslo';
$dir_img = '/home/httpd/html/sklep/img';
?>
Listing 8. Przykład pliku konfiguracyjnego cfg.php

Bezpieczeńśtwo

W tej części artykułu postaram się przedstawić najczęściej spotykane luki w skryptach tego typu oraz sposoby zabezpieczania aplikacji przed złośliwymi (nieuczciwymi) użytkownikami.


Tablice $HTTP_*_VARS

Bardzo zalecane jest stosowanie tablic asocjacyjnych z grupy $HTTP_*_VARS, pozwalają one jednoznacznie stwierdzić skąd dane pochodzą. Od wersji 4.1.0 dostępne są także tablice asocjacyjne (np. $_GET[]), które mają być zamiennikiem tych z rodziny $HTTP_*_VARS. Ich dużą zaletą jest krótszy zapis oraz globalny zasięg (w funkcjach nie trzeba ich deklarować za pomocą global $nazwa_zmiennej).

Część programistów PHP bagatelizuje problem, twierdząc, że skoro jest opcja variables_order w pliku php.ini i zmienne sesji zawsze nadpisują zmienne z innych źródeł (np. z GET) to praktycznie nie ma problemu. Przy źle napisanej aplikacji stosowanie globalnej przestrzeni nazewniczej może doprowadzić do poważnej luki w bezpieczeństwie.
      Aby skutecznie się podszyć pod zmienną sesji trzeba odgadnąć nazwę zmiennej i wartość jaką powinna przyjąć, jednak teoretycznie jest to możliwe. Dużo bardziej widoczny jest ten problem w przypadku formularzy, bardzo łatwo (jeśli nie sprawdzamy skąd zmienne pochodzą) jest udawać za pomocą parametrów URL'a zmienne, które powinny być wysłane metodą POST.
 
// plik1.php
<?php

// jeśli poniższy blok się nie wykona
if ($cos == $costam)
 {
 session_register(zalogowany, zm2);
 $zalogowany = 1;
 // ...
 }

?>

// plik2.php wywołany z parametrem zalogowany=1
<?php
session_start();
if ($zalogowany == 1) echo 'jesteś zalogowany';
?>
Listing 9. Podszycie się pod zmienną sesyjną za pomocą GET.

Przesyłanie zdjęć na serwer

Z tym zagadnieniem wiążą się dwa problemy. Pierwszy polega na sprawdzeniu czy pliki spełniają kryteria postawione przed aplikacją, a drugi na odpowiednim zabezpieczeniu katalogów w których pliki mają być przechowywane.
W przypadku uploadu plików metodą POST otrzymujemy tablicę $HTTP_POST_FILES w której mamy informacje o nazwie tymczasowej pliku (tmp_name), rozmiarze pliku (size) i oryginalnej nazwie pliku (name). Jest także udostępniana zmienna type zawierająca ustawiony przez przeglądarkę typ mime pliku np. image/gif.
Jeśli dopuszczamy tylko przesyłanie zdjęć warto dodatkowo sprawdzić typ pliku za pomocą funkcji GetImageSize() np. $size = GetImageSize($HTTP_POST_FILES[plik][tmp_name]);
Odpowiednie zabezpieczenie katalogów przeznaczonych na zdjęcia jest bardziej złożone.
      Najlepszym rozwiązaniem, wymagającym niestety własnego serwera lub bardzo dobrych stosunków z administratorem naszego ISP jest stworzenie osobnego konta FTP pozwalającego tylko i wyłącznie operować w katalogu img. W takim przypadku po przesłaniu pliku via formularz WWW na serwer, za pomocą funkcji FTP pobieramy plik z katalogu tymczasowego i umieszczamy w stosownym podkatalogu img. Dzięki czemu katalogom img/mini i img/big możemy nadać prawa dostępu 705.
Minusem tego sposobu jest to że wszelkie operacje na plikach zdjęć musimy wykonywać za pomocą FTP.
      Drugim z ciekawszych rozwiązań jest ustawienie przez administratora serwera odpowiednich grup i praw dostępu do w/w katalogów. Na przykład, serwer WWW chodzi na prawach użytkownika apache, nasz użytkownik to zdzichu. Prosimy więc administratora aby ustawił dla katalogów mini/ i big/ grupę apache, właściciela zdzichu i prawa dostępu 775.
      Trzecim rozwiązaniem, które możemy zrealizować praktycznie na każdym serwerze bez pomocy administratora jest przechowywanie zdjęć w katalogu o bardzo długiej i trudnej nazwie, umieszczonego najlepiej ponad DOCUMENT_ROOT.
Pobieranie zdjęć odbywa się wtedy za pomocą skryptu ustawiającego odpowiednie nagłówki, odczytującego porcjami plik z sekretnego miejsca i wysyłającego te dane do przeglądarki. Niestety takie rozwiązanie obciąża znacznie serwer.
      Istnieją także inne możliwości, np. włączenie trybu bezpiecznego (safe mode) w PHP, niestety mechanizm ten mimo bardzo dobrych założeń nie do końca spełnia swoje zadanie.
Innym rozwiązaniem stosowanym niestety bardzo rzadko (z powodu trudności w odpowiednim skonfigurowaniu) przez ISP jest uruchamianie serwera WWW z prawami właściciela wirtualnego serwera (virtual host), w takim przypadku wystarczą prawa 700.

Przechowywanie zakodowanych haseł

Wszelkie dane poufne takie jak np. hasła administratorów powinny być przechowywane w bazie danych w formie zaszyfrowanej. Można do tego wykorzystać standardową funkcję crypt() lub gdy potrzebujemy lepszego zabezpieczenia użyć funkcji z modułu mcrypt.
Podczas logowania wprowadzone hasło z formularza powinno być zakodowane następnie w skrypcie porównane z hasłem pobranym z bazy danych. Jeśli tylko istnieje taka możliwość to należy stosować bezpieczne protokoły sieciowe takie jak SSL do przesyłania formularza zamówieniowego i logowania.

Weryfikacja danych z formularza

Bardzo istotnym elementem zabezpieczenia się przed złośliwymi "klientami" jest skrupulatna weryfikacja danych pobranych z formularza zamówieniowego. Należy ją wykonywać po stronie serwera, wszelkie sprawdzanie client-side np. za pomocą JavaScript nie mają wielkiego znaczenia, ze względu na łatwość obejścia takich zabezpieczeń. Sprawdzać trzeba każde pole formularza, które jest wymagane do zawarcia transakcji. Kryteria, które zastosujemy powinny być dość surowe np. imię może zawierać tylko litery i musi mieć minimum 3 znaki.
Do tego typu weryfikacji niezbędne są wyrażenia regularne, za pomocą których możemy szybko, łatwo i przyjemnie sprawdzać nawet bardzo złożone kryteria.

Niekiedy warto wprowadzić obowiązek wypełnienia takich unikalnych danych jak NIP lub PESEL, pozwoli nam to zastosować algorytmy do sprawdzania poprawności tych danych i na starcie już odstraszyć wielu potencjalnych kawalarzy.
      Zachęcam do stosowania licznika błędnych wypełnień formularza, zliczających wszystkie kolejne próby wysłania błędnie wypełnionego formularza. Realizacja takiego licznika jest bardzo prosta, po zatwierdzeniu formularza następuje weryfikacja danych jeśli wystąpił jakikolwiek błąd to zwiększamy licznik błędów i zapisujemy do sesji (jeśli wcześniej nie istniał w sesji to wpisujemy 1).
Następnie zwracamy użytkownikowi do poprawki formularz z wpisanymi przez niego danymi oraz zaznaczonymi błędami. Po ponownym wysłaniu formularza procedura się powtarza.
W panelu administracyjnym obok danych zamówienia prezentujemy wskazanie licznika, na podstawie tych danych opiekun sklepu może podjąć decyzję czy warto ryzykować przy realizacji takiego zamówienia.
 
<?php

function check_pesel($pesel)
{
 global $pesel_sex;

 if (strlen($pesel) != 11 || !is_numeric($pesel))
    return 0;
 if (($pesel[9] % 2) == 0)
    $pesel_sex = ' kobieta';
    else $pesel_sex = 'mężczyzna';

  $steps = array(1, 3, 7, 9, 1, 3, 7, 9, 1, 3);
  for ($x = 0; $x < 10; $x++)
      $sum_nb += $steps[$x] * $pesel[$x];
  $sum_m = 10 - $sum_nb % 10;
  if ($sum_m == 10) $sum_m = 0;
  if ($sum_m == $pesel[10]) return 1;
  return 0;
}

?>
Listing 10. Funkcja sprawdzająca poprawność numeru PESEL i zwracająca płeć właściciela.

sklepy_lst.zip - Wszystkie listingi zawarte w artykule (4 KB).