Operacje na plikach w PHP

Wprowadzenie do obsługi plików

Obsługa plików w PHP pozwala aplikacjom webowym na trwałe przechowywanie danych oraz dostęp do zasobów plikowych serwera. Dzięki funkcjom do operacji na plikach możemy m.in. zapisywać logi, odczytywać konfiguracje, przetwarzać przesłane pliki czy generować raporty. W przeciwieństwie do operacji po stronie klienta (np. w JavaScript), operacje na plikach w PHP wykonywane są po stronie serwera i dotyczą plików znajdujących się w systemie plików serwera. Należy pamiętać, że skrypt PHP musi mieć odpowiednie uprawnienia do odczytu lub zapisu pliku na serwerze – inaczej operacja się nie powiedzie.

W niniejszym wykładzie omówimy podstawowe operacje na plikach: otwieranie i zamykanie plików, odczyt i zapis danych, tworzenie nowych plików oraz usuwanie plików. Przedstawimy praktyczne przykłady kodu PHP ilustrujące użycie funkcji takich jak fopen, fread, fwrite, fclose, file_get_contents, file_put_contents, unlink i innych. Ponadto poruszymy kwestie obsługi błędów i zabezpieczeń (sprawdzanie istnienia pliku, prawa dostępu, bezpieczne ścieżki), pracę z katalogami (tworzenie, usuwanie, odczyt zawartości katalogów) oraz przetwarzanie plików tekstowych, w tym formatu CSV. Na koniec przedstawimy dobre praktyki, takie jak buforowanie operacji, używanie blokad plików oraz unikanie ataków typu Path Traversal. Wszystkie przykłady będą opatrzone komentarzami ułatwiającymi zrozumienie kodu.

Otwieranie i zamykanie plików

Zanim rozpoczniemy odczyt lub zapis danych, musimy otworzyć plik. W PHP służy do tego funkcja fopen(), która zwraca tzw. wskaźnik (uchwyt) do pliku. Wywołujemy ją z dwoma podstawowymi argumentami: ścieżką/nazwą pliku oraz trybem otwarcia. Tryb określa, w jaki sposób chcemy plik otworzyć – np. tylko do odczytu, do zapisu, do dopisania danych na końcu, itd. Poniżej przedstawiono najczęściej używane tryby otwarcia plików:

  • 'r' – otwiera plik do odczytu (ang. read). Wskaźnik pliku ustawiany jest na początek. Plik musi istnieć, inaczej funkcja zwróci błąd (false).
  • 'w' – otwiera plik do zapisu (ang. write). Tworzy nowy plik, jeśli nie istnieje, lub czyści zawartość istniejącego pliku do zera (nadpisuje go od początku)​. Wskaźnik ustawiany jest na początek pliku.
  • 'a' – otwiera plik do dopisywania danych (ang. append). Wskaźnik ustawiany jest na koniec pliku, więc dane będą dopisywane. Jeśli plik nie istnieje, zostanie utworzony.
  • 'x' – otwiera plik do zapisu wyłącznie gdy plik nie istnieje. Używany do utworzenia nowego pliku; jeśli plik o podanej nazwie już istnieje, funkcja zwróci błąd. Ten tryb pozwala uniknąć przypadkowego nadpisania istniejącego pliku.
  • 'r+', 'w+', 'a+' – tryby łączone umożliwiające jednoczesny odczyt i zapis. Na przykład 'r+' otwiera plik do odczytu i zapisu (wymaga istnienia pliku), 'w+' tworzy nowy plik do odczytu i zapisu (lub czyści istniejący), a 'a+' otwiera plik do dopisywania i odczytu (wskazując na koniec).
  • 'b' – modyfikator trybu binarnego. W systemach Windows tryb tekstowy i binarny różnią się obsługą znaków końca linii. Dodanie 'b' (np. "rb" lub "wb") powoduje odczyt/zapis binarny bez modyfikacji danych (zalecane np. przy odczycie plików binarnych jak obrazy). W systemach Unix/Linux modyfikator ten nie wpływa na działanie, ale dla przenośności warto go używać przy danych binarnych.

Aby otworzyć plik, wywołujemy fopen() z odpowiednimi parametrami. Funkcja ta zwraca uchwyt do pliku (typu resource lub obiekt stream), którego będziemy używać w kolejnych operacjach na pliku. Jeśli otwarcie się nie powiedzie (np. plik nie istnieje w trybie 'r’ lub brak uprawnień), fopen() zwróci wartość false oraz wygeneruje ostrzeżenie (warning). Dlatego często łączy się fopen() z instrukcją warunkową lub operatorem tłumienia błędów. Przykładowe otwarcie pliku do odczytu:

 <?php
 $plik = fopen("dane.txt", "r");
 if ($plik === false) {
    // Obsługa błędu otwarcia pliku
    die("Nie udało się otworzyć pliku do odczytu!");
 }
 // ... (tutaj wykonujemy operacje na pliku) ...
 fclose($plik);
 ?>

W powyższym kodzie próbujemy otworzyć plik dane.txt w trybie odczytu. Jeśli fopen zwróci false, przerywamy działanie skryptu z komunikatem błędu (w rzeczywistej aplikacji zamiast die() można np. wyświetlić komunikat użytkownikowi lub podjąć inną akcję). Gdy plik zostanie pomyślnie otwarty, na zmiennej $plik mamy uchwyt do pliku i możemy wykonywać na nim dalsze operacje.

Zamykanie pliku: Po zakończeniu pracy z plikiem należy go zamknąć funkcją fclose($uchwyt). Zamknięcie pliku zwalnia zasoby systemowe związane z plikiem. Wprawdzie PHP automatycznie zamyka wszystkie otwarte pliki po zakończeniu skryptu, ale zaleca się zamykanie ich explicite, gdy tylko przestają być potrzebne. W powyższym przykładzie zamykamy plik wywołując fclose($plik) na końcu.

Uwaga: Uchwyt pliku (np. zmienna $plik zwrócona przez fopen) jest potrzebny do większości operacji niskopoziomowego odczytu i zapisu. Niektóre uproszczone funkcje (jak file_get_contents czy file_put_contents) nie wymagają jawnego otwierania i zamykania pliku – robią to wewnętrznie – ale w przypadku korzystania z fopen() zawsze pamiętaj o fclose().

Odczyt danych z pliku

Po otwarciu pliku do odczytu możemy pobrać z niego dane na kilka sposobów. Najprostszą funkcją do czytania z pliku (przy użyciu uchwytu) jest fread($uchwyt, $length), która odczytuje z pliku określoną liczbę bajtów. Najczęściej używa się jej, gdy chcemy wczytać całą zawartość pliku na raz – wtedy jako $length podajemy rozmiar pliku w bajtach. Rozmiar pliku można uzyskać np. funkcją filesize($nazwa_pliku). Przykład odczytu całego pliku za pomocą fread():

 <?php
 $plik = fopen("dane.txt", "r");
 if ($plik) {
    $rozmiar = filesize("dane.txt");           // pobranie rozmiaru pliku w bajtach
    $zawartosc = fread($plik, $rozmiar);       // odczyt calej zawartosci
    fclose($plik);
    echo "Zawartość pliku:\n$zawartosc";
 } else {
    echo "Błąd: nie można otworzyć pliku.";
 }
 ?>

W powyższym kodzie otwieramy plik, następnie fread() wczytuje $rozmiar bajtów (czyli cały plik) do zmiennej $zawartosc, po czym plik jest zamykany, a zawartość wyświetlana. Należy jednak uważać przy tej metodzie – jeśli plik jest bardzo duży, próba wczytania go w całości do pamięci może obciążyć serwer lub przekroczyć limity pamięci. W takich sytuacjach lepiej czytać plik stopniowo, fragmentami.

Odczyt liniami lub fragmentami: PHP udostępnia funkcję fgets($uchwyt), która odczytuje jedną linię tekstu z pliku (do napotkania znaku nowej linii \n lub do osiągnięcia zadanej długości). Można jej użyć do wczytywania pliku linia po linii w pętli. Przykład odczytu pliku linia po linii:

 <?php
 $plik = fopen("dane.txt", "r");
 if ($plik) {
    while (($linia = fgets($plik)) !== false) {
        // Usuwamy ewentualny znak nowej linii z końca linii:
        $linia = rtrim($linia, "\r\n");
        echo "Przeczytano: $linia\n";
    }
    fclose($plik);
 }
 ?>

W tej pętli while funkcja fgets() zwraca kolejną linię tekstu przy każdym wywołaniu, a gdy osiągnie koniec pliku, zwróci false i pętla się zakończy. Używamy rtrim aby usunąć znaki końca linii. Alternatywnie można użyć funkcji feof($uchwyt) (end-of-file) w warunku pętli, np.:

 while (!feof($plik)) {
    $linia = fgets($plik);
    if ($linia !== false) {
        // ...
    }
 } 

Jednak preferowanym sposobem iteracji jest ten pierwszy, z bezpośrednim sprawdzaniem wyniku fgets().

Szybkie wczytanie całego pliku: Często wygodniejsze jest skorzystanie z funkcji file_get_contents($filename), która czyta cały plik na raz bez potrzeby użycia fopen() i fclose(). Zwraca ona zawartość pliku jako jeden duży łańcuch tekstowy (string) lub false w razie błędu. Przykład:

 <?php
 $dane = file_get_contents("dane.txt");
 if ($dane === false) {
    echo "Błąd odczytu pliku.";
 } else {
    echo "Zawartość:\n$dane";
 }
 ?>

Funkcja file_get_contents jest bardzo prosta w użyciu – wystarczy podać nazwę pliku. Według dokumentacji jest to preferowany sposób odczytywania zawartości pliku do pojedynczego łańcucha znaków (wykorzystuje wewnętrzne mechanizmy, takie jak mapowanie pliku w pamięci, aby przyspieszyć operację)​w3schools.com. Należy tylko pamiętać, że analogicznie jak w przypadku fread całego pliku, użycie file_get_contents na bardzo dużym pliku może być niewydajne pamięciowo – w takich przypadkach czytanie porcjami jest bezpieczniejsze.

Przykład: Jeśli plik dane.txt zawiera np. listę nazwisk, powyższy kod jednorazowo wczyta całą zawartość i wyświetli ją. Gdybyśmy chcieli przetworzyć plik stopniowo (np. wypisać numerowane linie), lepszy byłby wariant z fgets w pętli.

PHP oferuje także inne funkcje pomocnicze, np. file($filename), która czyta cały plik do tablicy, gdzie każdy element to jeden wiersz tekstu pliku. Można ją wykorzystać, jeśli chcemy łatwo iterować po liniach bez samodzielnego pisania pętli – ale podobnie jak file_get_contents, wczytuje wszystko do pamięci na raz.

Podsumowując, aby odczytać plik w PHP:

  1. Otwieramy go (fopen) w trybie odczytu 'r' (lub 'r+' jeśli także będziemy zapisywać).
  2. Używamy odpowiedniej funkcji do czytania: fread (określoną liczbę bajtów), fgets (po jednej linii), lub prostszych metod jak file_get_contents/file.
  3. Po zakończeniu odczytu zamykamy plik (fclose).

Zapis danych do pliku

Zapis do pliku w PHP również można wykonać na dwa sposoby: przy użyciu uchwytu pliku (fopen + fwrite()/fputs()) lub korzystając z wygodnej funkcji file_put_contents(). Omówimy najpierw podejście z fwrite.

Aby zapisać dane, otwieramy plik w trybie zapisu, np. 'w' (zawsze zaczynamy od początku, nadpisując) lub 'a' (dopisywanie na końcu). Jeżeli plik nie istnieje, tryb 'w' i 'a' spowodują jego utworzenie​. Następnie wywołujemy fwrite($uchwyt, $dane), aby zapisać ciąg znaków do pliku. Funkcja ta zwraca liczbę zapisanych bajtów lub false w przypadku błędu.

Przykład zapisu: Utwórzmy nowy plik i zapiszmy w nim kilka linii tekstu:

 <?php
 $plik = fopen("wyjscie.txt", "w");
 if (!$plik) {
    die("Nie można otworzyć pliku do zapisu!");
 }
 $tekst1 = "Linia pierwsza\n";
 $tekst2 = "Linia druga\n";
 fwrite($plik, $tekst1);
 fwrite($plik, $tekst2);
 fclose($plik);
 echo "Dane zostały zapisane do pliku.";
 ?>

W tym kodzie otwieramy (lub tworzymy) plik wyjscie.txt w bieżącym katalogu i zapisujemy do niego dwie linie tekstu. Zwróć uwagę na dodawanie znaku nowej linii \n na końcu każdej linii – fwrite nie dodaje automatycznie znaków końca linii, zapisuje dokładnie to, co znajduje się w przekazanym łańcuchu. Po zakończeniu zapisu zamykamy plik. Po wykonaniu skryptu plik wyjscie.txt będzie zawierał:

Linia pierwsza
Linia druga

Jeśli w powyższym przykładzie plik wyjscie.txt już istniał, został nadpisany od początku (wcześniejsza zawartość utracona). Gdybyśmy chcieli dopisywać do istniejącego pliku bez kasowania poprzednich danych, należy otworzyć go w trybie 'a' (append):

 $plik = fopen("log.txt", "a"); fwrite($plik, "Nowy wpis logu\n");fclose($plik);

Powyższy fragment dopisze na końcu pliku log.txt nową linię. Tryb 'a' gwarantuje, że wskaźnik jest zawsze na końcu – nawet jeśli inny proces dopisze coś w międzyczasie, nasz fwrite i tak dołoży dane na końcu aktualnego stanu pliku.

PHP posiada również funkcję file_put_contents($filename, $data, [$flags]), która upraszcza proces zapisu. Wewnątrz jest ona odpowiednikiem kolejnych wywołań fopen(), fwrite() i fclose()​ – wystarczy jedna linijka, aby zapisać dane do wskazanego pliku. Jeśli plik o podanej nazwie nie istnieje, zostanie utworzony; jeśli istnieje – domyślnie jego zawartość zostanie nadpisana od początku​php.net. Możemy jednak przekazać opcjonalną flagę FILE_APPEND, aby dopisywać dane na końcu pliku zamiast go nadpisywać.

Przykłady użycia file_put_contents():

 // Zapis do nowego pliku (lub nadpisanie istniejącego):
 file_put_contents("raport.txt", "Treść raportu\n");

 // Dopisanie do pliku (nie kasuje poprzedniej zawartości):
 file_put_contents("raport.txt", "Kolejna linia raportu\n", FILE_APPEND);

Pierwsza linia utworzy plik raport.txt z podaną zawartością (lub zastąpi całą zawartość jeśli plik istniał). Druga linia demonstruje użycie flagi FILE_APPEND – sprawia ona, że tekst zostanie dodany na końcu pliku raport.txt zamiast nadpisywać go od początku. Funkcja file_put_contents zwraca liczbę zapisanych bajtów lub false w przypadku błędu, więc również można (choć rzadziej się to robi) sprawdzić jej wynik dla pewności.

Uwaga: file_put_contents ma też flagę LOCK_EX, która powoduje założenie wyłącznej blokady na plik podczas zapisu​. Omówimy to szerzej w sekcji o blokadach plików, ale w skrócie: jeśli wykonujemy jednoczesny zapis z wielu procesów/PHP (np. przy dużym ruchu), warto użyć LOCK_EX, by uniknąć nadpisywania się danych (dzieje się to kosztem krótkiego zablokowania pliku na czas zapisu).

Tworzenie nowych plików

Tworzenie pliku w PHP następuje zazwyczaj po prostu poprzez jego otwarcie w trybie zapisu. Jak już wspomniano, tryby takie jak 'w' czy 'a' spowodują utworzenie pliku, jeśli nie istnieje​. Nie istnieje osobna funkcja „utwórz pusty plik” – zamiast tego używamy fopen. Na przykład:

 // Utworzenie pustego pliku (ew. wyczyszczenie istniejącego):
 fopen("nowy_plik.txt", "w");
 fclose($plik);

Powyższy kod (otwórz i zaraz zamknij w trybie 'w') utworzy plik nowy_plik.txt o zerowej długości. Oczywiście zazwyczaj od razu po otwarciu dokonujemy zapisu danych. Można również użyć funkcji touch("plik.txt"), która działa podobnie jak polecenie touch w systemie – tworzy pusty plik lub aktualizuje czas modyfikacji pliku, jeśli już istnieje.

Warto pamiętać, że jeżeli chcemy utworzyć nowy plik, ale zabezpieczyć się przed wyścigiem z innym procesem (dwa procesy naraz próbujące utworzyć plik o tej samej nazwie) lub przed przypadkowym nadpisaniem istniejącego pliku, lepiej użyć trybu 'x'. Tryb 'x' spowoduje, że jeśli plik już istnieje, fopen zwróci błąd zamiast go otwierać. Możemy to wykorzystać do bezpiecznego tworzenia plików, które nie powinny już istnieć (np. unikalnych plików tymczasowych).

Usuwanie plików

Usuwanie (kasowanie) pliku z dysku realizuje funkcja unlink($filename). Nazwa pochodzi od funkcji systemowej unlink w Uniksie i oznacza usunięcie powiązania nazwy pliku z jego zawartością (w praktyce – skasowanie pliku). Funkcja unlink przyjmuje ścieżkę do pliku i zwraca true w przypadku powodzenia lub false gdy operacja się nie powiedzie​ (np. gdy plik nie istnieje lub brak uprawnień). W razie błędu pojawi się także ostrzeżenie PHP. Przykład:

 <?php
 $plik = "dane.txt";
 if (file_exists($plik)) {
    if (unlink($plik)) {
        echo "Plik $plik został usunięty.";
    } else {
        echo "Błąd: nie udało się usunąć pliku $plik.";
    }
 } else {
    echo "Plik $plik nie istnieje.";
 }
 ?>

Tutaj najpierw sprawdzamy, czy plik istnieje (funkcją file_exists). Jeśli tak, wywołujemy unlink. W zależności od wyniku informujemy, czy operacja się powiodła. W praktyce, często wywołuje się po prostu unlink("sciezka/do/pliku") bez sprawdzania istnienia – jeśli plik nie istnieje, to i tak nie ma co usuwać (ewentualny warning można wyłapać lub zignorować). Natomiast sprawdzenie jest przydatne, gdy chcemy zareagować na brak pliku inaczej niż na błąd przy próbie usunięcia.

Uwaga: Usunięcie otwartego pliku – na systemach Unixowych – nie spowoduje błędu, jeśli mamy uprawnienia, ale dopóki jakiś proces trzyma uchwyt do tego pliku, plik fizycznie pozostanie na dysku (bez nazwy) i zostanie ostatecznie zwolniony dopiero po zamknięciu ostatniego uchwytu. W systemie Windows natomiast nie można usunąć pliku otwartego przez jakikolwiek proces. Dlatego bezpieczniej jest zawsze zamknąć plik (fclose), a dopiero potem usuwać go unlinkiem. Od PHP 7.3 zniesiono część ograniczeń na Windows (można usunąć plik otwarty przez ten sam proces PHP), ale wciąż nie można utworzyć nowego pliku o tej samej nazwie dopóki stary uchwyt nie zostanie zamknięty​.

Podsumowując sekcję podstawową: potrafimy już otworzyć plik, odczytać jego zawartość różnymi metodami, zapisać dane do pliku, utworzyć nowy plik oraz usunąć plik. Następnie zajmiemy się bardziej zaawansowanymi kwestiami: jak radzić sobie z błędami i uprawnieniami, jak operować na katalogach oraz jak bezpiecznie przetwarzać dane plikowe.

Obsługa błędów i zabezpieczenia przy pracy z plikami

Operacje na plikach mogą napotkać różne problemy – plik może nie istnieć, możemy nie mieć uprawnień do jego odczytu/zapisu, dysk może być pełny, ścieżka nieprawidłowa, itp. Dlatego ważne jest, aby obsługiwać błędy i upewnić się, że operacje są wykonywane w bezpieczny sposób.

Sprawdzanie istnienia i uprawnień pliku

Zanim otworzymy plik do odczytu lub spróbujemy go usunąć, warto sprawdzić, czy faktycznie istnieje. Służy do tego funkcja file_exists($sciezka), która zwraca true/false. Podobnie można użyć is_file($sciezka) (sprawdza, czy istnieje i czy jest zwykłym plikiem) lub is_dir($sciezka) dla katalogów. Przykład użycia:

 if (!file_exists("konfig.txt")) {
     echo "Plik konfiguracyjny nie istnieje!";
 } else {
    $fp = fopen("konfig.txt", "r");
    // ...
 } 

Jeśli zamierzamy zapisywać dane, przydatne mogą być funkcje is_writable($sciezka) i is_readable($sciezka), które sprawdzają uprawnienia do zapisu lub odczytu. Przykładowo:

 if (file_exists("dane.txt") && !is_writable("dane.txt")) {
    echo "Brak uprawnień do zapisu pliku dane.txt";
 } 

Warto zauważyć, że is_writable zwróci false także wtedy, gdy plik nie istnieje (bo nie można pisać do czegoś, czego nie ma). Jeżeli więc chcemy utworzyć plik, lepiej sprawdzić uprawnienia do katalogu, w którym plik ma powstać.

Łapanie błędów funkcji plikowych

Większość funkcji operujących na plikach nie rzuca wyjątków, ale zwraca wartości oznaczające błąd (np. false). Dodatkowo PHP generuje ostrzeżenia (warnings), które nie przerywają skryptu, ale są wyświetlane (lub logowane). Sposoby obsługi:

  • Sprawdzanie zwracanych wartości: jak pokazywaliśmy w przykładach, zawsze warto weryfikować wynik fopen, fread, fwrite, file_get_contents, unlink itd. przed założeniem, że operacja się udała.
  • Blokowanie ostrzeżeń: można użyć operatora @ przed wywołaniem funkcji, np. $fp = @fopen("plik.txt", "r");. To spowoduje, że ewentualny komunikat ostrzegawczy nie pojawi się. Nie jest to jednak zalecane na etapie debugowania, bo ukrywa informację o błędzie. Lepiej przechwycić błąd inaczej lub po prostu sprawdzić wynik.
  • Użycie funkcji error_get_last() po nieudanym wywołaniu może dać szczegóły ostatniego błędu (np. No such file or directory lub Permission denied). Można to wykorzystać do logowania problemów.
  • Wyjątki: standardowe funkcje plikowe nie wykorzystują wyjątków, ale istnieją obiektowe odpowiedniki (np. klasa SplFileObject), które mogą rzucać wyjątki. W tym wykładzie skupimy się na funkcjach proceduralnych, więc obsługa polega głównie na if/else.

Bezpieczne ścieżki i dane wejściowe

Bardzo ważnym aspektem bezpieczeństwa jest walidacja i kontrola ścieżek do plików używanych w funkcjach. Nigdy nie należy bezkrytycznie używać danych pochodzących od użytkownika jako fragmentu ścieżki pliku na serwerze. Klasycznym atakiem jest tzw. Path Traversal (Traversal Directory), gdzie atakujący manipulując ścieżką (najczęściej dodając ../) próbuje uzyskać dostęp do plików poza dozwolonym katalogiem aplikacji.

Path Traversal polega na takim skonstruowaniu parametru (np. nazwy pliku przekazanej w URL lub formularzu), by uzyskać dostęp do plików, do których normalnie aplikacja nie powinna zaglądać (np. plików konfiguracyjnych systemu). Dzieje się to poprzez „wychodzenie” do katalogów nadrzędnych za pomocą ciągów "../" w ścieżce​sekurak.pl. Jeśli aplikacja nie filtruje takich sekwencji, ktoś może np. zażądać pliku ../../etc/passwd zamiast dozwolonego dane/uzytkownik.txt. W rezultacie serwer może ujawnić wrażliwe pliki.

Jak się przed tym bronić? Nigdy nie ufaj ścieżce lub nazwie pliku podanej przez użytkownika. Kilka dobrych praktyk:

  • Walidacja nazwy pliku: Jeżeli użytkownik ma podać nazwę pliku do otwarcia, dopuszczaj tylko konkretny format nazwy, np. same litery/cyfry bez ukośników. Można użyć wyrażeń regularnych lub funkcji ctype. Warto też ograniczyć dopuszczalne rozszerzenia (np. tylko .txt).
  • Nie pozwalaj na znaki ../: Najprostszym zabezpieczeniem jest odrzucenie wejścia, które zawiera ciąg ".." lub inne podejrzane sekwencje (jak zaczynające się od „/” itp.). Jednak samo usuwanie ciągów "../" może być niewystarczające – atakujący mogą użyć zaskakujących zapisów (np. zakodowanych URL-owo), które ominą naiwny filtr​sekurak.pl. Lepiej całkowicie zablokować dane wejściowe zawierające kropki i ukośniki, jeśli oczekujemy zwykłej nazwy pliku.
  • Użycie bezpiecznej ścieżki bazowej: Można zdefiniować pewien katalog bazowy, w którym trzymamy pliki do odczytu/zapisu (np. uploads/ lub dane/). Następnie łączyć go z podaną nazwą pliku i kanonicznie przetwarzać. Pomocna jest funkcja realpath(), która zwraca kanoniczną (oczyszczoną) pełną ścieżkę do pliku. Przykładowo, realpath("dane/../config.php") zwróci pełną ścieżkę bez fragmentów ../. Po uzyskaniu realpath możemy sprawdzić, czy dany plik faktycznie znajduje się w naszym dozwolonym katalogu bazowym. Prosty schemat: phpKopiujEdytuj$baseDir = __DIR__ . "/dane/"; // dozwolony katalog bazowy $podanaNazwa = $_GET["plik"]; // np. parametr z URL $sciezka = realpath($baseDir . $podanaNazwa); if ($sciezka === false || strpos($sciezka, $baseDir) !== 0) { die("Nieprawidłowa ścieżka pliku!"); } // teraz $sciezka jest pełną, bezpieczną ścieżką w katalogu dane/ $fp = fopen($sciezka, "r"); W powyższym kodzie realpath zwróci pełną ścieżkę do pliku jeśli on istnieje (inaczej zwróci false). Następnie strpos sprawdza, czy ta pełna ścieżka zaczyna się od naszego katalogu bazowego (czyli czy plik leży w nim, a nie gdzie indziej). Jeżeli warunek nie jest spełniony, przerywamy działanie – oznacza to, że ktoś próbował wyjść poza dozwoloną lokalizację lub plik nie istnieje.
  • Wykorzystanie basename(): Czasem wystarczy ograniczyć wejście tylko do nazwy pliku bez ścieżki. Funkcja basename($sciezka) zwróci ostatni komponent ścieżki, ignorując resztę. Np. basename("../../../etc/passwd") da "passwd"acunetix.com. Możemy więc zrobić $plik = basename($_GET["plik"]) i użyć tej nazwy w naszym katalogu: fopen("dane/$plik", "r"). W ten sposób ciągi ../ zostaną niejako obcięte (bo basename wyrzuci wszystko oprócz nazwy pliku). Jednak uwaga – jeśli ktoś poda samą nazwę np. passwd i nasza aplikacja użyje go w katalogu dane/, to nie wyjdzie poza katalog, ale pliku może tam nie być. Z tego powodu metoda basename() bywa łączona z realpath() dla pełnej pewności​.

Podsumowując: najlepszą obroną przed atakami Path Traversal jest poprawna walidacja i ograniczenie danych wejściowych. Jeśli to możliwe, nie pozwalaj użytkownikowi wprowadzać dowolnej ścieżki – lepiej dać wybór z listy lub użyć identyfikatorów plików zamiast nazw. Jeżeli jednak musisz użyć nazwy od użytkownika, czyść i sprawdzaj ją dokładnie (usuniecie niedozwolonych znaków, użycie realpath, porównanie do dozwolonej ścieżki). Jak wskazuje OWASP i inni eksperci, unikanie czarnej listy (blokowania tylko pewnych znaków) jest ważne – lepsze jest podejście białej listy (zezwalanie tylko na konkretne znaki)​.

Inne aspekty bezpieczeństwa

  • Uprawnienia plików: Upewnij się, że pliki, którymi operuje PHP, mają odpowiednie prawa w systemie plików. Pliki, które nie powinny być modyfikowane przez proces webserwera, niech mają tylko do odczytu (0444 lub 0644 bez prawa zapisu przez użytkownika serwera). Katalogi z uploadami plików powinny mieć restrykcyjne uprawnienia i często mechanizmy dodatkowe (np. brak możliwości wykonania skryptów z tego katalogu, by ktoś nie wgrał złośliwego PHP).
  • try…catch przy SPL: Jeśli używasz obiektowych klas plikowych (jak SplFileObject), pamiętaj o blokach try/catch, bo np. metoda SplFileObject::__construct rzuci wyjątek przy nieudanym otwarciu pliku.
  • Konfiguracja PHP: W środowiskach produkcyjnych warto ustawić open_basedir w php.ini – ogranicza on katalogi, do których PHP może mieć dostęp. Nie zwalnia to z obowiązku walidacji, ale stanowi dodatkową warstwę ochrony. Ponadto wyłączanie nieużywanych funkcji (np. jeśli nie potrzebujemy możliwości usuwania plików, można poprzez disable_functions zablokować unlink – to drastyczny środek, rzadko stosowany, ale wspominany dla kompletności).
  • Backupy: Operując na ważnych plikach (np. nadpisując pliki konfiguracyjne), rozważ tworzenie kopii zapasowych przed zapisem.

Praca z katalogami (folderami)

Operacje na katalogach są podobnie ważne jak na pojedynczych plikach. PHP udostępnia funkcje do tworzenia i usuwania katalogów, a także przeglądania ich zawartości.

Tworzenie katalogu

Aby utworzyć nowy katalog (folder) na dysku, używamy funkcji mkdir($sciezka, $uprawnienia, $rekurencyjnie). Podajemy ścieżkę (może być względna lub absolutna). Drugi parametr to prawa dostępu dla nowego katalogu w notacji ósemkowej (analogicznie jak w Unixie, np. 0775). Jeśli go pominiemy, domyślnie będzie to 0777 zmodyfikowane przez bieżącą maskę umask (zazwyczaj więc efektywnie 0755). Trzeci parametr (opcjonalny) to bool oznaczający, czy tworzyć ewentualnie rekurencyjnie całą strukturę katalogów. Jeśli ustawimy go na true, a w podanej ścieżce jest kilka poziomów nieistniejących katalogów, PHP spróbuje utworzyć je wszystkie po kolei. Przykład:

 // Utworzenie pojedynczego katalogu "dane"
 if (!file_exists("dane")) {
    mkdir("dane", 0755);
 }

 // Utworzenie zagnieżdżonych katalogów "upload/2025/zdjecia"
 mkdir("upload/2025/zdjecia", 0777, true);

W powyższym kodzie najpierw tworzymy katalog dane (o ile jeszcze nie istnieje). Ustawiamy uprawnienia na 0755 (w systemie Unix oznacza: właściciel może czytać/zapisywać, grupa i inni tylko czytać i wykonywać – typowe dla folderów webowych). Następnie jednym poleceniem mkdir z opcją rekurencyjną tworzymy całą strukturę katalogów: najpierw upload, w nim 2025, w nim zdjecia. Gdy recursive jest true, PHP nie zgłosi błędu jeśli któryś katalog po drodze już istnieje – po prostu pominie tworzenie tych istniejących i utworzy brakujące.

Funkcja mkdir zwraca true/false w zależności od sukcesu operacji, więc warto to sprawdzić lub użyć w warunku, aby wychwycić ewentualne błędy (np. brak uprawnień do utworzenia katalogu w danej lokalizacji).

Usuwanie katalogu

Do usunięcia pustego katalogu służy funkcja rmdir($sciezka). Uwaga – katalog musi być pusty (nie zawierać żadnych plików ani podkatalogów), inaczej rmdir się nie powiedzie. Przykład:

 if (file_exists("stary")) {
    $ok = rmdir("stary");
    if ($ok) {
        echo "Katalog został usunięty.";
    } else {
        echo "Nie udało się usunąć katalogu. Sprawdź, czy jest pusty.";
    }
 } 

Jeśli potrzebujemy usunąć katalog wraz z całą jego zawartością (rekurencyjnie), PHP nie ma pojedynczej wbudowanej funkcji do tego (poza wykorzystaniem np. polecenia systemowego exec('rm -rf …'), czego raczej unikamy). Trzeba w takiej sytuacji najpierw usunąć wszystkie pliki w katalogu (np. w pętli), a następnie sam katalog. Można napisać własną funkcję rekurencyjną do usuwania katalogów. W naszym wykładzie skupiamy się na podstawach, więc zakładamy usuwanie katalogów pustych za pomocą rmdir.

Odczyt zawartości katalogu

Często zachodzi potrzeba uzyskania listy plików w danym katalogu. Możemy to osiągnąć na kilka sposobów:

  • opendir(), readdir(), closedir() – zestaw niskopoziomowych funkcji podobny koncepcją do fopen. opendir($sciezka) otwiera katalog i zwraca uchwyt (zasób) katalogu. Następnie wielokrotne wywołanie readdir($uchwyt_katalogu) zwraca kolejne nazwy wpisów (pliki lub podkatalogi) z tego katalogu, lub false gdy dotrze do końca. Gdy skończymy, zamykamy katalog closedir($uchwyt).
  • scandir($sciezka) – wygodna funkcja, która zwraca tablicę nazw zawartości katalogu (działa nieco jak połączenie opendir+readdir które od razu czyta całość). Można ją użyć, gdy chcemy po prostu szybko dostać listę plików.
  • glob($wzorzec) – ta funkcja nie tyle czyta katalog bezpośrednio, co zwraca listę plików pasujących do podanego wzorca (np. *.txt dla wszystkich plików .txt). Internie też przegląda katalog, ale pozwala od razu filtrować wyniki po wzorcu.

Pokażemy przykład z użyciem opendir/readdir, aby zobrazować podstawowy mechanizm:

 <?php
$dir = "dane";
if (is_dir($dir)) {
if ($dh = opendir($dir)) {
echo "Zawartość katalogu $dir:\n";
while (($plik = readdir($dh)) !== false) {
if ($plik === "." || $plik === "..") continue; // pomijamy bieżący i nadrzędny katalog
echo " - $plik\n";
}
closedir($dh);
} else {
echo "Nie można otworzyć katalogu $dir.";
}
} else {
echo "Katalog $dir nie istnieje.";
}
?>


W powyższym kodzie sprawdzamy najpierw is_dir, czy dane jest istniejącym katalogiem. Następnie opendir otwiera katalog i zwraca uchwyt do przeglądania. W pętli while pobieramy kolejne wpisy. Każdy wpis to nazwa pliku lub podkatalogu (np. "plik1.txt", "obrazek.png", "podkatalog"). Zawsze w każdym katalogu są specjalne wpisy "." (oznaczający ten sam katalog) oraz ".." (rodzic, katalog nadrzędny) – zazwyczaj chcemy je pominąć, co robimy instrukcją continue gdy zostaną napotkane. Resztę wyświetlamy. Na końcu zamykamy katalog closedir.

Jeśli zamiast wypisywać chcielibyśmy np. zebrać te nazwy do tablicy, można w pętli dodawać do tablicy, lub prościej – użyć wspomnianej funkcji scandir:

 $pliki = scandir($dir);
 foreach ($pliki as $plik) {
    if ($plik !== "." && $plik !== "..") {
        echo "Znaleziono: $plik\n";
    }
 } 

scandir zwraca już tablicę, którą iterujemy. W praktyce to podejście jest krótsze, ale mniej elastyczne w przypadku bardzo dużych katalogów (gdzie nie chcemy wszystkiego naraz w pamięci, choć to rzadko bywa problemem).

Pobieranie informacji o plikach: Mając listę nazw możemy też od razu sprawdzać pewne informacje o każdym z nich, np. czy dany wpis jest katalogiem czy plikiem (is_file/is_dir), jaka jest jego wielkość (filesize), data modyfikacji (filemtime), itd. PHP oferuje sporo funkcji file* oraz stat do uzyskiwania szczegółów – ale to już materiał na osobny temat.

Na marginesie, dla bardziej obiektowego podejścia, PHP ma klasy takie jak DirectoryIterator czy FilesystemIterator (z przestrzeni SPL), które pozwalają przeglądać katalog w stylu iteracji obiektowej. Dla początkujących jednak proste opendir/readdir lub scandir są w zupełności wystarczające.

Przetwarzanie plików tekstowych i CSV

Pliki tekstowe są najprostszą formą przechowywania danych – mogą to być np. logi, pliki .txt z paragrafami tekstu, wartości oddzielone przecinkami (CSV) czy inne dane w formacie tekstowym. W tej sekcji zajmiemy się technikami wczytywania i interpretowania takich danych.

Praca z prostymi plikami tekstowymi

Załóżmy, że mamy prosty plik tekstowy lista.txt, w którym każda linia zawiera jedno słowo (np. lista haseł, imion, itp.). Chcemy wczytać ten plik i np. wyświetlić numerowane linie na stronie. Możemy wykorzystać poznane już metody odczytu liniowego:

 <?php
 $fp = fopen("lista.txt", "r");
 if (!$fp) {
    die("Nie można otworzyć pliku lista.txt");
 }
 $i = 1;
 while (($linia = fgets($fp)) !== false) {
    $linia = rtrim($linia, "\r\n");
    echo $i++ . ". " . $linia . "<br>";
 }
 fclose($fp);
 ?>

Tutaj odczytujemy plik linia po linii i dodajemy numerację. Używamy rtrim by pozbyć się znaków nowej linii przed doklejeniem "<br>" (znacznika HTML nowej linii). W wyniku np. plik zawierający:

jabłko
gruszka
śliwka

zostanie wypisany jako:

1. jabłko
2. gruszka
3. śliwka

Inny scenariusz: plik tekstowy może zawierać pary klucz-wartość, np. konfigurację w formie:

 host=localhost user=root
 pass=secret

Aby to przetworzyć, możemy czytać linia po linii, użyć explode("=", $linia) do podziału na klucz i wartość i zbudować np. tablicę asocjacyjną:

 $config = [];
 $fp = fopen("config.ini", "r");
 while (($linia = fgets($fp)) !== false) {
    $linia = trim($linia);
    if ($linia === "" || $linia[0] === "#") continue; // pomiń puste i komentarze
    list($klucz, $wartosc) = explode("=", $linia, 2);
    $config[$klucz] = $wartosc;
 }
 fclose($fp);
 // Teraz $config['host'] będzie np. "localhost"

To tylko przykład jak można obrabiać dane tekstowe własnym kodem. PHP posiada również funkcje parse_ini_file do takich plików .ini, ale chciałem zilustrować manualne przetwarzanie linii tekstu.

Format CSV (Comma-Separated Values)

Pliki CSV to popularny format przechowywania tabelarycznych danych w formacie tekstowym. W CSV poszczególne pola (kolumny) są oddzielone przecinkami (lub innym separatorem, np. średnikiem), a rekordy (wiersze) są oddzielone nowymi liniami. Przykładowy plik CSV dane.csv może wyglądać tak:

imie,nazwisko,wiek
Jan,Kowalski,30
Anna,Nowak,25
Piotr,Zieliński,40

Pierwsza linia często jest nagłówkiem z nazwami kolumn, a kolejne to dane.

Ręczne parsowanie CSV (np. użycie explode(",",$linia)) może działać dla prostych przypadków, ale zawodzi, gdy pola zawierają przecinki w wartościach lub są ujęte w cudzysłowy. Dlatego PHP udostępnia specjalne funkcje:

  • fgetcsv($uchwyt, $długość=0, $delimiter=',', $enclosure='"', $escape='\\') – czyta jedną linię CSV z otwartego pliku i zwraca tablicę wartości. Parametry określają separator (domyślnie przecinek), znacznik zawarcia pola (domyślnie cudzysłów) i znak escape.
  • fputcsv($uchwyt, $tablica, $delimiter=',', $enclosure='"', $escape='\\') – zapisuje tablicę wartości jako jedną linię CSV do pliku (dokłada separator i ewentualne cudzysłowy automatycznie).

Użycie tych funkcji jest bardzo wygodne, bo zajmują się wszystkimi zawiłościami formatu (np. jeśli pole zawiera przecinek, fgetcsv zauważy, że jest ono ujęte w cudzysłowy i nie rozbije go na dwa pola).

Przykład odczytu pliku CSV:

Załóżmy, że mamy plik pracownicy.csv w formacie: imie,nazwisko,email (bez nagłówka dla uproszczenia tutaj). Możemy go wczytać następująco:

 <?php
 $fp = fopen("pracownicy.csv", "r");
 if (!$fp) {
    die("Nie udało się otworzyć pliku CSV.");
 }
 echo "<ul>\n";
 while (($wiersz = fgetcsv($fp)) !== false) {
    // $wiersz jest tablicą elementów [imie, nazwisko, email]
    if (count($wiersz) < 3) continue; // na wypadek pustej linii
    list($imie, $nazwisko, $email) = $wiersz;
    echo "<li>$imie $nazwisko — email: $email</li>\n";
 }
 echo "</ul>\n";
 fclose($fp);

 ?>

Ten kod otwiera plik CSV, następnie w pętli while pobiera kolejne wiersze jako tablicę. Rozpakowujemy tablicę na trzy zmienne $imie, $nazwisko, $email i wykorzystujemy je (tu akurat generując listę HTML). Po zakończeniu zamykamy plik. fgetcsv automatycznie przechodzi do następnej linii przy kolejnym wywołaniu.

Przykład zapisu do pliku CSV:

Załóżmy, że mamy tablicę danych, którą chcemy zapisać jako plik CSV. Możemy użyć fputcsv. Poniżej zapisujemy kilka wierszy do pliku wynik.csv:

 <?php
 $dane = [
    ["Produkt", "Cena", "Ilość"],
    ["Jabłka", "3.50", "10"],
    ["Gruszki", "4.20", "5"],
 ];
 $fp = fopen("wynik.csv", "w");
 foreach ($dane as $wiersz) {
    fputcsv($fp, $wiersz); // domyślny delimiter to ',', więc CSV oddzielone przecinkami
 }
 fclose($fp);
 echo "Zapisano plik wynik.csv";
 ?>

Po uruchomieniu tego kodu plik wynik.csv będzie zawierał:

Produkt,Cena,Ilość
Jabłka,3.50,10
Gruszki,4.20,5

Funkcja fputcsv sama dodaje znak nowej linii na końcu każdego zapisanego wiersza, więc nie musimy go dopisywać. Gdybyśmy chcieli użyć innego separatora (np. średnika ;, który jest popularny w polskich wersjach Excela jako separator CSV), możemy podać go jako trzeci argument fputcsv($fp, $wiersz, ";") oraz analogicznie fgetcsv($fp, 0, ";") przy odczycie.

Uwaga dot. kodowania znaków: Jeśli plik CSV zawiera polskie znaki, upewnij się, że używane jest poprawne kodowanie (najlepiej UTF-8). PHP nie zmienia kodowania, zapisuje bajty jak leci. Dlatego jeśli generujesz CSV z polskimi znakami do otwarcia np. w Excelu, czasem wymagane jest dodanie na początku specjalnego BOM lub użycie kodowania Windows-1250 – to jednak poza zakresem tego wykładu. W naszych przykładach zakładamy UTF-8 wszędzie.

Dobre praktyki przy operacjach na plikach

Na koniec omówmy kilka dobrych praktyk i wskazówek, które warto stosować podczas programowania operacji plikowych w PHP. Dzięki nim nasze skrypty będą bardziej efektywne, bezpieczne i mniej podatne na błędy.

Buforowanie operacji wejścia/wyjścia

Buforowanie odnosi się do optymalizacji polegającej na zmniejszeniu liczby bezpośrednich operacji dyskowych przez grupowanie danych. Dostęp do dysku bywa stosunkowo wolny, więc czytając lub pisząc plik, lepiej robić to w większych blokach niż bajt po bajcie.

  • Czytanie dużych plików: Zamiast czytać cały plik naraz do pamięci (co może zużywać dużo RAM), można czytać go porcjami, np. po 4KB czy 8KB danych, przetwarzać tę porcję i wczytywać następną. W PHP można to osiągnąć wywołując fread w pętli z ograniczoną długością lub używając fgets jeśli dane są liniowe. Np.: phpKopiujEdytuj$fp = fopen("bigfile.dat", "r"); while (!feof($fp)) { $chunk = fread($fp, 8192); // czytaj 8KB na raz // ... przetwórz $chunk ... } fclose($fp); Taka pętla pozwoli obchodzić się nawet z bardzo dużymi plikami bez ryzyka przeciążenia pamięci – w danym momencie przetwarzamy tylko fragment.
  • Pisanie w pętli: Jeśli masz zamiar w pętli dopisywać do pliku wiele małych fragmentów, rozważ alternatywę:
    • Albo otwórz plik raz przed pętlą, zapisuj kolejne fragmenty fwrite i zamknij po pętli (to na szczęście naturalne podejście i tak było w naszych przykładach; unikaj otwierania i zamykania pliku przy każdym przebiegu pętli, bo to ogromny narzut).
    • Albo zbuforuj dane w zmiennej i zapisz je jednorazowo. Np. buduj duży string przez konkatenację lub (lepiej przy wielu operacjach) użyj tablicy i implode – a na koniec zapisz całość funkcją file_put_contents. To czasem łatwiejsze niż martwienie się o synchronizację przy równoczesnych zapisach.
    • Buforowanie ma granice – nie chcemy też na siłę trzymać w pamięci ogromu danych. Trzeba dobrać strategię do rozmiaru danych.
  • Wykorzystanie wbudowanych mechanizmów: Jak już wspomniano, funkcja file_get_contents używa wewnętrznie wydajnych technik (jak memory mapping) by przyspieszyć odczyt dużych plików​w3schools.com. Z kolei file_put_contents jest napisana w C i z pewnymi optymalizacjami, więc często będzie szybsza niż równoważny kod PHP wykonujący pętlę fwrite. Korzystaj z tych wyższego poziomu funkcji gdy to możliwe – nie tylko upraszczają kod, ale i mogą działać szybciej.
  • Output buffering (bufor wyjścia): Choć to temat związany bardziej z wysyłaniem danych do przeglądarki, czasem używa się mechanizmu bufora wyjścia, by zgromadzić duży tekst generowany przez skrypt (np. HTML lub CSV) i zapisać go do pliku jedną funkcją zamiast dokonywać wielu zapisów. Można np. zrobić: phpKopiujEdytujob_start(); // generuj dużo danych, używając echo, print itd. $calosc = ob_get_clean(); file_put_contents("duzyraport.txt", $calosc); Tutaj ob_start włącza buforowanie wyjścia (nic nie jest wysyłane do przeglądarki, tylko trafia do bufora), następnie generujemy zawartość, ob_get_clean pobiera cały bufor jako string i czyści go, po czym file_put_contents zapisuje wszystko do pliku. To wygodny sposób, by przekierować generowanie np. raportu HTML do pliku na dysku.

Podsumowując, chodzi o to, by minimalizować liczbę operacji I/O. Lepiej zrobić jedno duże fwrite niż sto małych, jeśli to możliwe. Buforowanie i batch processing danych zawsze powinny iść w parze z rozwagą – czasem upraszczamy kod kosztem kilku dodatkowych I/O i to też jest w porządku, dopóki nie wpływa na wydajność w zauważalny sposób.

Blokady plików (współbieżny dostęp)

W środowisku webowym, gdzie wiele skryptów PHP może uruchamiać się jednocześnie (np. wielu użytkowników jednocześnie dokonuje operacji), istnieje ryzyko, że dwa procesy spróbują jednocześnie pisać do tego samego pliku lub jeden czyta, gdy inny akurat go modyfikuje. To może prowadzić do niespójnych danych lub uszkodzenia pliku. Aby temu zapobiec, stosuje się mechanizm blokad plików (ang. file locking).

PHP udostępnia funkcję flock($uchwyt, $mode) do zakładania i zdejmowania blokady na plik. Użycie:

  • $mode może być LOCK_SH (udziałowa blokada do odczytu – wiele procesów może mieć taką jednocześnie, o ile nikt nie ma wyłącznej) albo LOCK_EX (wyłączna blokada do zapisu – tylko jeden proces może mieć ją na dany plik). Dodatkowo można dodać LOCK_NB żeby nie czekać na zwolnienie blokady (non-blocking).
  • Wywołujemy flock($fp, LOCK_EX) przed krytyczną sekcją (np. przed zapisem do pliku), a gdy skończymy, wywołujemy flock($fp, LOCK_UN) żeby zwolnić blokadę.
  • Blokadę zawsze zakładamy na otwarty uchwyt pliku. To znaczy plik musi być otwarty (np. przez fopen) zanim wykonamy flock.

Przykład scenariusza: Mamy plik licznik.txt, który przechowuje jakąś liczbę (licznik odwiedzin strony). Każde odświeżenie strony skryptem ma zwiększyć ten licznik o 1. Bez blokad, jeśli dwa zapytania w tym samym czasie spróbują to zrobić, może dojść do tzw. race condition – oba odczytają starą wartość X, oba zapiszą X+1, a powinniśmy dostać X+2 net sumarycznie.

Rozwiązanie z blokadą:

 <?php
 $fp = fopen("licznik.txt", "c+");  // tryb c+ umożliwia odczyt i zapis bez kasowania zawartości
 flock($fp, LOCK_EX);              // zakładamy wyłączną blokadę
 // Sekcja krytyczna - tylko jeden proces naraz tu wejdzie
 $wartosc = (int)fread($fp, 100);  // odczytaj bieżącą wartość (zakładamy, że jest mała)
 fseek($fp, 0);                    // cofnij wskaźnik na początek, by nadpisać od początku
 $wartosc++;
 fwrite($fp, $wartosc);
 // Koniec sekcji krytycznej
 flock($fp, LOCK_UN);              // zwolnij blokadę
 fclose($fp);
 ?>

W powyższym kodzie użyliśmy trybu "c+" do otwarcia pliku. Tryb ten (od „create”) jest podobny do "r+" z tą różnicą, że utworzy plik jeśli nie istnieje (ale nie kasuje zawartości jak „w”). Przydaje się do liczników, logów, itp. gdzie chcemy edytować plik „w miejscu”. Zakładamy blokadę exkluzywną LOCK_EX – od tego momentu, jeśli inny proces spróbuje wykonać flock na ten sam plik, będzie czekał aż zwolnimy (chyba że użył LOCK_NB, wtedy dostanie natychmiastowy błąd). Z blokadą odczytujemy wartość, zwiększamy ją, zapisujemy z powrotem. Ważne jest użycie fseek – po odczytaniu wskaźnik był na końcu pliku, musieliśmy cofnąć na początek przed zapisem, by nadpisać starą wartość. Następnie zdejmujemy blokadę.

Taki mechanizm gwarantuje, że tylko jeden proces naraz modyfikuje licznik, więc nic się nie „zgubi”. Gdyby dwóch użytkowników jednocześnie wywołało ten skrypt, drugi poczeka w miejscu flock($fp, LOCK_EX) aż pierwszy wykona flock($fp, LOCK_UN).

Dla operacji czysto odczytowych można stosować blokadę współdzieloną LOCK_SH, która pozwala wielu procesom czytać jednocześnie, ale zablokuje zapis w tym czasie (proces, który będzie chciał założyć LOCK_EX, poczeka aż wszyscy czytający zwolnią LOCK_SH). W praktyce w PHP rzadko jawnie zakłada się blokady dla odczytu – raczej dba się o blokady przy zapisie.

Warto wspomnieć, że file_put_contents ma wygodny skrót: flagę LOCK_EX, którą można podać w trzecim argumencie (lub OR razem z FILE_APPEND). Gdy użyjemy file_put_contents("plik.txt", $dane, LOCK_EX), funkcja ta wewnętrznie wykona flock na czas zapisu​php.net. To ułatwia bezpieczny zapis, jeśli nie chcemy sami wywoływać flock.

Blokady plików w PHP są advisory, co oznacza, że działają tylko jeśli wszystkie zainteresowane procesy przestrzegają ich. To my musimy używać flock – jeśli jakiś inny proces zignoruje to i będzie pisał bez blokady, to nasza blokada go nie powstrzyma. W większości zastosowań webowych skrypty PHP to jedyne coś co dotyka te pliki, więc jest w porządku.

Unikanie ataków typu Path Traversal

Ten temat omówiliśmy już częściowo w sekcji o bezpiecznych ścieżkach, ale powtórzmy najważniejsze jako dobrą praktykę bezpieczeństwa: waliduj i filtruj wszelkie dane, które wpływają na wybór pliku lub katalogu. Atak Path Traversal, jak opisano, ma na celu wyjście poza zamierzony katalog i uzyskanie dostępu do niedozwolonych zasobów​sekurak.pl. Aby się przed nim uchronić:

  • Ograniczaj wybór plików – np. jeżeli użytkownik ma wybierać avatar spośród dostępnych, zamiast pozwalać mu wpisać nazwę pliku, daj listę opcji.
  • Jeżeli musisz przyjąć ścieżkę/nazwę pliku od użytkownika, stosuj funkcje takie jak basename i realpath do normalizacji ścieżki i upewnienia się, że pozostaje w dozwolonym obszarze​.
  • Stosuj listy dozwolonych wartości tam, gdzie to możliwe. Np. zamiast pozwolić użytkownikowi podać rozszerzenie pliku, narzuć z góry, że akceptowane są tylko .jpg i .png (i sprawdź to po otrzymaniu pliku).
  • Uświadom sobie, że prosty filtr usuwający ciągi "../" może być łatwo obejdowany​. Atakujący mogą np. potroić kropki, dodawać ukośniki w różnej postaci kodowania (np. %2e to kropka, %2f to / w kodowaniu URL). Dlatego nie próbuj na ślepo czyścić, raczej weryfikuj poprawność.

Dobrym nawykiem jest przemyślenie, jakie najgorsze działanie może wykonać atakujący manipulując ścieżką w naszym skrypcie, i upewnienie się, że scenariusz jest zablokowany. W kontekście plików, najgorsze to odczyt lub nadpisanie plików konfiguracyjnych, haseł, lub wykonanie przesłanego skryptu. Oprócz walidacji samej ścieżki, pamiętajmy też, by nie nadawać katalogom z plikami użytkownika prawa wykonywania skryptów (czyli jeśli ktoś wgra shell.php jako plik, żeby nie można go było potem odwiedzić w przeglądarce i uruchomić). To już jednak zagadnienie konfiguracji serwera (np. trzymanie uploadów poza katalogiem publicznym).

Inne dobre praktyki

  • Zamykanie plików tak szybko, jak to możliwe: Nie trzymaj otwartego uchwytu dłużej niż potrzebne. Zwłaszcza gdy używasz blokad – natychmiast po zapisie zwalniaj blokadę i zamykaj plik, aby inni mogli z niego korzystać.
  • Transakcje plikowe (atomiczność zapisu): Jeśli zapisujesz ważne dane, które nie powinny zostać uszkodzone przy przerwaniu, rozważ strategię zapisu do pliku tymczasowego i podmiany. Np. zapisz do plik.txt.tmp, a po sukcesie zamień nazwy (funkcją rename) – dzięki temu zawsze albo stary albo nowy plik będzie kompletny. To zabezpiecza przed sytuacją, że np. skrypt przerwał się w trakcie zapisu i plik docelowy jest częściowo nadpisany.
  • Logowanie błędów: Jeżeli operacja plikowa się nie uda, dobrze jest mieć w logach informację dlaczego. Np. error_get_last() może posłużyć do zapisu komunikatu błędu (ścieżka, powód) do logu, co ułatwi debug.
  • Testy i uprawnienia środowiska: Warto przetestować nasz kod plikowy w warunkach zbliżonych do docelowych – np. czy na serwerze produkcyjnym skrypt ma prawo zapisu tam gdzie myślimy. Często w środowiskach developerskich mamy pełne uprawnienia i wszystko działa, a na produkcji np. brak prawa do jakiegoś folderu powoduje błędy. Dlatego po wdrożeniu funkcjonalności plikowych, sprawdź logi serwera czy nie pojawiają się ostrzeżenia o prawach dostępu.

Podsumowanie

Operacje na plikach w PHP obejmują szeroki zakres czynności – od prostego odczytu/zapisu po zarządzanie całymi katalogami i dbanie o bezpieczeństwo. W tej prezentacji przedstawiliśmy podstawowe funkcje i ich zastosowania: od otwierania plików (fopen) i czytania (fread, fgets, file_get_contents), przez zapis (fwrite, file_put_contents) po tworzenie (mkdir, tryby zapisu) i usuwanie plików (unlink, rmdir). Omówiliśmy też sposoby radzenia sobie z błędami (sprawdzanie wyników funkcji, istnienia plików, uprawnień) oraz zabezpieczania ścieżek przed atakami. Przyjrzeliśmy się, jak przetwarzać pliki tekstowe i CSV przy użyciu wygodnych narzędzi wbudowanych w język PHP, co znacznie ułatwia zadania importu/eksportu danych. Na koniec podkreśliliśmy znaczenie dobrych praktyk: odpowiedniego buforowania operacji dla wydajności, stosowania blokad plików przy dostępie współbieżnym i zachowania zasad bezpieczeństwa, by nasze aplikacje były odporne na typowe ataki i błędy.

Uzbrojeni w tę wiedzę, początkujący i średnio zaawansowani programiści PHP powinni czuć się pewniej w pracy z systemem plików. Zachęcam do dalszych eksperymentów – np. spróbowania odczytu i zapisu innych formatów (JSON, XML) czy użycia obiektowych interfejsów SPL do plików – pamiętając jednocześnie o fundamentach, które tutaj omówiliśmy. Powodzenia w implementacji bezpiecznych i efektywnych operacji na plikach w Waszych projektach PHP!