21. PHP – Superglobals


Szybki quiz – przypomnienie poprzedniej lekcji

Lekcja 20 jest w kursie pomijana, więc pytania dotyczą Lekcji 19: INSERT, UPDATE, DELETE z PHP.

  1. Co wypisze ten kod, jeśli gracz o id = 2 istnieje w bazie?

    $premia = 0;
    $uzytkownikId = 2;
    $zapytanie = $polaczenie->prepare('UPDATE uzytkownicy SET xp = xp + ? WHERE id = ?');
    $zapytanie->bind_param('ii', $premia, $uzytkownikId);
    $zapytanie->execute();
    var_dump($zapytanie->affected_rows);
    
  2. Kolega usuwa zadanie tak:

    $zapytanie = $polaczenie->prepare('DELETE FROM zadania WHERE id = ?');
    $zapytanie->bind_param('i', $id);
    

    $id przychodzi z ukrytego pola formularza. Zapytanie jest przygotowane, więc SQL injection nie grozi. Co mimo to jest tu błędem i jak to naprawić?

  3. Dlaczego bind_param('ii', 5, $uzytkownikId) kończy się błędem, a bind_param('ii', $premia, $uzytkownikId) już nie?

  4. Który krok znany z SELECT odpada przy UPDATE i DELETE? Czym zastępujemy sprawdzenie wyniku?

  5. Dlaczego XP przyznajemy tylko wtedy, gdy affected_rows po zapytaniu

    UPDATE zadania SET data_wykonania = CURDATE()
    WHERE id = ? AND uzytkownik_id = ? AND data_wykonania IS NULL
    

    wynosi 1, zamiast najpierw wczytać zadanie i sprawdzić w PHP if (!$zadanie->czyWykonane())?

  6. dodaj-zadanie.php po udanym zapisie wywołuje header('Location: index.php'); exit;. Co zobaczyłby gracz, gdyby zamiast tego skrypt wypisał „Dodano zadanie” i gracz nacisnął F5?

Odpowiedzi
  1. int(0). Wiersz pasuje do WHERE, ale xp + 0 to ta sama wartość, więc nic się nie zmieniło. affected_rows przy UPDATE liczy wiersze zmienione, nie pasujące.
  2. Brakuje warunku na właściciela: WHERE id = ? AND uzytkownik_id = ? z $_SESSION['uzytkownik_id']. Ukryte pole formularza może podmienić każdy (F12), więc bez tego warunku gracz usunie cudze zadanie. To nie SQL injection, tylko brak sprawdzenia uprawnień — prepared statement przed tym nie chroni.
  3. bind_param() przyjmuje argumenty przez referencję, czyli tylko zmienne. Stała 5 nie ma adresu w pamięci, który można przekazać — stąd Error: ... could not be passed by reference. Wartość trzeba najpierw zapisać w zmiennej.
  4. Odpada get_result() — INSERT, UPDATE i DELETE nie zwracają wierszy. Efekt sprawdzamy przez $zapytanie->affected_rows (a po INSERT dodatkowo insert_id).
  5. Bo między odczytem a zapisem może wejść drugie żądanie (dwie karty, podwójne kliknięcie). Oba przeszłyby przez if i oba dodałyby XP. Warunek data_wykonania IS NULL wewnątrz UPDATE sprawia, że tylko jedno żądanie rzeczywiście zmieni wiersz i dostanie affected_rows równe 1.
  6. Przeglądarka zapytałaby o ponowne wysłanie danych formularza i po potwierdzeniu wykonałaby ten sam INSERT jeszcze raz — powstałyby duplikaty zadania. Wzorzec POST → przekierowanie → GET sprawia, że F5 odświeża tylko index.php.

TaskQuest

Dodatek

Superglobals (superzmienne globalne) to specjalne wbudowane tablice w PHP, które są automatycznie dostępne w każdym  zakresie (scope) – w funkcjach, metodach, klasach i skryptach.

Nie musisz ich deklarować ani importować za pomocą słowa kluczowego global.

Poznaliśmy już tablice $_GET, $_POST, $_SESSION i $_COOKIE. 

UWAGA: ZAWSZE waliduj dane – nigdy nie ufaj danym od użytkownika

Lista wszystkich Superglobals w PHP

PHP udostępnia 9 superglobalnych tablic:

SuperglobalZastosowanieKiedy używać
$_GETDane z URL (query string)Parametry URL, linki, wyszukiwanie
$_POSTDane z formularzy POSTFormularze, logowanie, rejestracja
$_REQUESTDane z GET, POST i COOKIERzadko – lepiej używać $_GET lub $_POST
$_FILESPrzesłane plikiUpload plików, zdjęć, dokumentów
$_SESSIONDane sesji użytkownikaPrzechowywanie stanu zalogowania
$_COOKIECookies przeglądarkiZapamiętywanie preferencji użytkownika
$_SERVERInformacje o serwerze i żądaniuAdres IP, user agent, ścieżki
$_ENVZmienne środowiskoweKonfiguracja, dane wrażliwe
$GLOBALSWszystkie zmienne globalneDostęp do zmiennych globalnych

1. $_GET – Dane z URL (Query String)

Dane są przekazywane w adresie URL po znaku ? w formacie klucz=wartość, oddzielone znakiem &.

profil.php?id=5&username=ProGamer&sort=level

Odczytywanie danych z $_GET

<?php
// profil.php

// Sprawdzamy czy parametr istnieje
if (isset($_GET['id'])) {
    $player_id = (int)$_GET['id']; // Rzutowanie na int dla bezpieczeństwa
    echo "ID gracza: {$player_id}<br>";
} else {
    echo "Brak parametru ID w URL<br>";
}

// Operator null coalescing (PHP 7+)
$username = $_GET['username'] ?? 'Nieznany';
$sort = $_GET['sort'] ?? 'id';

echo "Username: {$username}<br>";
echo "Sortowanie: {$sort}<br>";

// Wyświetl wszystkie parametry GET
echo "<pre>";
print_r($_GET);
echo "</pre>";
?>

Wyjście dla URL profil.php?id=5&username=ProGamer&sort=level:

ID gracza: 5
Username: ProGamer
Sortowanie: level

Array
(
    [id] => 5
    [username] => ProGamer
    [sort] => level
)

Przykład 1: Wyszukiwarka z parametrami GET

<?php
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');

if ($db->connect_error) {
    die('Błąd połączenia');
}

$db->set_charset('utf8mb4');

// Pobieramy parametry z URL
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['q'])) {
    $search_query = trim($_GET['q']);
    $min_level = (int)($_GET['min_level'] ?? 0);
    $rank = $_GET['rank'] ?? '';
    
    if (!empty($search_query)) {
        
        // Budujemy zapytanie dynamicznie
        $sql = "SELECT * FROM players WHERE (username LIKE ? OR email LIKE ?)";
        $params = ["%{$search_query}%", "%{$search_query}%"];
        $types = "ss";
        
        if ($min_level > 0) {
            $sql .= " AND level >= ?";
            $types .= "i";
            $params[] = $min_level;
        }
        
        if (!empty($rank)) {
            $sql .= " AND rank_name = ?";
            $types .= "s";
            $params[] = $rank;
        }
        
        $sql .= " ORDER BY level DESC LIMIT 20";
        
        $stmt = $db->prepare($sql);
        $stmt->bind_param($types, ...$params);
        $stmt->execute();
        $wynik = $stmt->get_result();
        
        $safe_query = htmlspecialchars($search_query);
        echo "<h2>Wyniki wyszukiwania dla: \"{$safe_query}\"</h2>";
        
        if ($wynik->num_rows > 0) {
            echo "<p>Znaleziono: {$wynik->num_rows} graczy</p>";
            
            while ($gracz = $wynik->fetch_assoc()) {
                $username = htmlspecialchars($gracz['username']);
                echo "<div style='background:#f4f4f4; padding:10px; margin:5px 0;'>";
                echo "<strong>{$username}</strong> - Level: {$gracz['level']}, Rank: {$gracz['rank_name']}<br>";
                echo "<a href='profil.php?id={$gracz['id']}'>Zobacz profil</a>";
                echo "</div>";
            }
        } else {
            echo "<p>Nie znaleziono graczy</p>";
        }
        
        $stmt->close();
    }
}

$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Wyszukiwarka graczy</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 50px auto;
            padding: 20px;
        }
        .search-form {
            background: #f4f4f4;
            padding: 20px;
            border-radius: 5px;
            margin-bottom: 30px;
        }
        input, select {
            padding: 10px;
            margin: 5px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        button {
            padding: 10px 20px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        button:hover {
            background: #764ba2;
        }
    </style>
</head>
<body>

<h1>Wyszukiwarka graczy</h1>

<form method="GET" class="search-form">
    <input type="text" 
           name="q" 
           placeholder="Wpisz nick lub email" 
           value="<?= htmlspecialchars($_GET['q'] ?? '') ?>"
           required>
    
    <input type="number" 
           name="min_level" 
           placeholder="Min. poziom"
           min="1"
           value="<?= htmlspecialchars($_GET['min_level'] ?? '') ?>">
    
    <select name="rank">
        <option value="">Wszystkie rangi</option>
        <option value="Bronze" <?= ($_GET['rank'] ?? '') == 'Bronze' ? 'selected' : '' ?>>Bronze</option>
        <option value="Silver" <?= ($_GET['rank'] ?? '') == 'Silver' ? 'selected' : '' ?>>Silver</option>
        <option value="Gold" <?= ($_GET['rank'] ?? '') == 'Gold' ? 'selected' : '' ?>>Gold</option>
        <option value="Diamond" <?= ($_GET['rank'] ?? '') == 'Diamond' ? 'selected' : '' ?>>Diamond</option>
    </select>
    
    <button type="submit">Szukaj</button>
</form>

<p><small>Przykłady wyszukiwań:</small></p>
<ul>
    <li><a href="?q=Pro">?q=Pro</a></li>
    <li><a href="?q=Gamer&min_level=20">?q=Gamer&min_level=20</a></li>
    <li><a href="?q=Test&rank=Gold">?q=Test&rank=Gold</a></li>
</ul>

</body>
</html>

Kiedy używać $_GET?

  • Wyszukiwanie i filtrowanie
  • Paginacja (?page=2)
  • Sortowanie (?sort=date&order=desc)
  • Linki do konkretnych zasobów (?id=123)
  • Parametry, które użytkownik może udostępnić (share)

Nie stosujemy do:

  • Haseł i danych wrażliwych (widoczne w URL!)
  • Modyfikacji danych w bazie (INSERT, UPDATE, DELETE)
  • Dużych ilości danych (limit ~2000 znaków)

2. $_POST – Dane z formularzy POST

Dane są wysyłane w ciele żądania HTTP (nie w URL), więc są niewidoczne dla użytkownika.

Odczytywanie danych z $_POST

<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    // Sprawdzamy czy pole istnieje
    if (isset($_POST['username'])) {
        $username = trim($_POST['username']);
        echo "Username: {$username}<br>";
    }
    
    // Operator null coalescing
    $email = trim($_POST['email'] ?? '');
    $age = (int)($_POST['age'] ?? 0);
    
    echo "Email: {$email}<br>";
    echo "Wiek: {$age}<br>";
    
    // Wyświetl wszystkie dane POST
    echo "<pre>";
    print_r($_POST);
    echo "</pre>";
}
?>

Formularz rejestracji z walidacją

<?php
session_start();

mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');

if ($db->connect_error) {
    die('Błąd połączenia');
}

$db->set_charset('utf8mb4');

$errors = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    // Pobieramy wszystkie dane z formularza
    $username = trim($_POST['username'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';
    $password_confirm = $_POST['password_confirm'] ?? '';
    $age = (int)($_POST['age'] ?? 0);
    $terms = isset($_POST['terms']) ? 1 : 0; // Checkbox
    
    // Walidacja
    if (empty($username)) {
        $errors[] = "Username jest wymagany";
    } elseif (strlen($username) < 3) {
        $errors[] = "Username musi mieć min. 3 znaki";
    }
    
    if (empty($email)) {
        $errors[] = "Email jest wymagany";
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors[] = "Nieprawidłowy format email";
    }
    
    if (empty($password)) {
        $errors[] = "Hasło jest wymagane";
    } elseif (strlen($password) < 8) {
        $errors[] = "Hasło musi mieć min. 8 znaków";
    }
    
    if ($password !== $password_confirm) {
        $errors[] = "Hasła nie są identyczne";
    }
    
    if ($age < 13) {
        $errors[] = "Musisz mieć co najmniej 13 lat";
    }
    
    if (!$terms) {
        $errors[] = "Musisz zaakceptować regulamin";
    }
    
    // Jeśli brak błędów - zapisujemy
    if (empty($errors)) {
        
        // Sprawdzamy czy username już istnieje
        $stmt = $db->prepare("SELECT id FROM users WHERE username = ?");
        $stmt->bind_param("s", $username);
        $stmt->execute();
        $stmt->store_result();
        
        if ($stmt->num_rows > 0) {
            $errors[] = "Username '{$username}' jest już zajęty";
        }
        $stmt->close();
        
        if (empty($errors)) {
            $password_hash = password_hash($password, PASSWORD_DEFAULT);
            
            $stmt = $db->prepare("INSERT INTO users (username, email, password, age, created_at) VALUES (?, ?, ?, ?, NOW())");
            $stmt->bind_param("sssi", $username, $email, $password_hash, $age);
            
            if ($stmt->execute()) {
                $new_user_id = $stmt->insert_id;
                
                $stmt->close();
                $db->close();
                
                $_SESSION['registration_success'] = "Konto utworzone pomyślnie!";
                
                header("Location: logowanie.php");
                exit;
            } else {
                $errors[] = "Błąd podczas rejestracji";
            }
            
            $stmt->close();
        }
    }
}

$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Rejestracja</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: Arial, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }
        .container {
            background: white;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.2);
            max-width: 500px;
            width: 100%;
        }
        h1 {
            text-align: center;
            color: #333;
            margin-bottom: 30px;
        }
        .form-group {
            margin-bottom: 20px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            color: #555;
            font-weight: 500;
        }
        input[type="text"],
        input[type="email"],
        input[type="password"],
        input[type="number"] {
            width: 100%;
            padding: 12px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
        }
        input:focus {
            outline: none;
            border-color: #667eea;
        }
        .checkbox-group {
            display: flex;
            align-items: center;
            gap: 10px;
        }
        .checkbox-group input[type="checkbox"] {
            width: 20px;
            height: 20px;
            cursor: pointer;
        }
        .checkbox-group label {
            margin: 0;
            cursor: pointer;
        }
        button {
            width: 100%;
            padding: 12px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
        }
        button:hover {
            background: #764ba2;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #f5c6cb;
        }
        .error ul {
            margin-left: 20px;
        }
    </style>
</head>
<body>

<div class="container">
    <h1>Rejestracja</h1>
    
    <?php if (!empty($errors)): ?>
        <div class="error">
            <strong>Błędy walidacji:</strong>
            <ul>
                <?php foreach ($errors as $error): ?>
                    <li><?= htmlspecialchars($error) ?></li>
                <?php endforeach; ?>
            </ul>
        </div>
    <?php endif; ?>
    
    <form method="POST">
        <div class="form-group">
            <label>Username:</label>
            <input type="text" 
                   name="username" 
                   value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
                   required>
        </div>
        
        <div class="form-group">
            <label>Email:</label>
            <input type="email" 
                   name="email"
                   value="<?= htmlspecialchars($_POST['email'] ?? '') ?>"
                   required>
        </div>
        
        <div class="form-group">
            <label>Hasło:</label>
            <input type="password" 
                   name="password"
                   required>
        </div>
        
        <div class="form-group">
            <label>Powtórz hasło:</label>
            <input type="password" 
                   name="password_confirm"
                   required>
        </div>
        
        <div class="form-group">
            <label>Wiek:</label>
            <input type="number" 
                   name="age"
                   min="13"
                   max="120"
                   value="<?= htmlspecialchars($_POST['age'] ?? '') ?>"
                   required>
        </div>
        
        <div class="form-group">
            <div class="checkbox-group">
                <input type="checkbox" 
                       name="terms" 
                       id="terms"
                       <?= isset($_POST['terms']) ? 'checked' : '' ?>
                       required>
                <label for="terms">Akceptuję regulamin</label>
            </div>
        </div>
        
        <button type="submit">Zarejestruj się</button>
    </form>
</div>

</body>
</html>

Kiedy używać $_POST?

  • Logowanie i rejestracja
  • Formularze kontaktowe
  • Modyfikacja danych (INSERT, UPDATE, DELETE)
  • Przesyłanie dużych ilości danych
  • Dane wrażliwe (hasła, dane osobowe)

Złe zastosowania:

  • Wyszukiwanie (lepiej GET – możliwość udostępnienia linku)
  • Linki do konkretnych stron (GET jest lepsze)

$_REQUEST – Połączenie GET, POST i COOKIE

$_REQUEST zawiera dane z $_GET, $_POST i $_COOKIE.

<?php
// Przykład: odbiera dane zarówno z GET jak i POST
$search = $_REQUEST['search'] ?? '';

echo "Wartość search: {$search}";
?>

UWAGA: $_REQUEST jest niezalecany ze względów bezpieczeństwa. Lepiej użyć konkretnej tablicy ($_GET lub $_POST).

4. $_FILES – Przesyłanie plików

Struktura $_FILES

Gdy użytkownik wysyła plik, PHP automatycznie wypełnia tablicę $_FILES:

$_FILES['nazwa_pola'] = [
    'name' => 'oryginalnanazwa.jpg',      // Nazwa pliku
    'type' => 'image/jpeg',               // Typ MIME
    'tmp_name' => '/tmp/phpXXXXXX',       // Tymczasowa lokalizacja
    'error' => 0,                         // Kod błędu (0 = OK)
    'size' => 245678                      // Rozmiar w bajtach
];

Przykład 3: Upload zdjęcia profilowego

<?php
session_start();

$errors = [];
$success = false;

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['avatar'])) {
    
    $file = $_FILES['avatar'];
    
    // Sprawdzamy czy nie ma błędu uploadu
    if ($file['error'] !== UPLOAD_ERR_OK) {
        $errors[] = "Błąd podczas uploadu pliku (kod: {$file['error']})";
    } else {
        
        // Walidacja rozmiaru (max 2MB)
        $max_size = 2 * 1024 * 1024; // 2MB w bajtach
        if ($file['size'] > $max_size) {
            $errors[] = "Plik jest za duży (max 2MB)";
        }
        
        // Walidacja typu pliku
        $allowed_types = ['image/jpeg', 'image/png', 'image/gif'];
        if (!in_array($file['type'], $allowed_types)) {
            $errors[] = "Nieprawidłowy typ pliku (dozwolone: JPG, PNG, GIF)";
        }
        
        // Dodatkowa walidacja przez rozszerzenie
        $file_extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
        $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif'];
        if (!in_array($file_extension, $allowed_extensions)) {
            $errors[] = "Nieprawidłowe rozszerzenie pliku";
        }
        
        // Jeśli brak błędów - zapisujemy plik
        if (empty($errors)) {
            
            // Generujemy unikalną nazwę pliku
            $new_filename = uniqid('avatar_', true) . '.' . $file_extension;
            $upload_dir = 'uploads/avatars/';
            
            // Tworzymy katalog jeśli nie istnieje
            if (!is_dir($upload_dir)) {
                mkdir($upload_dir, 0755, true);
            }
            
            $destination = $upload_dir . $new_filename;
            
            // Przenosimy plik z lokalizacji tymczasowej
            if (move_uploaded_file($file['tmp_name'], $destination)) {
                $success = true;
                $uploaded_file_path = $destination;
                
                // Możesz zapisać ścieżkę do bazy danych
                // UPDATE users SET avatar = ? WHERE id = ?
                
            } else {
                $errors[] = "Nie udało się zapisać pliku";
            }
        }
    }
}
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Upload avatara</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 600px;
            margin: 50px auto;
            padding: 20px;
        }
        .upload-form {
            background: #f4f4f4;
            padding: 30px;
            border-radius: 10px;
        }
        input[type="file"] {
            margin: 20px 0;
            padding: 10px;
            border: 2px dashed #ddd;
            border-radius: 5px;
            width: 100%;
            cursor: pointer;
        }
        button {
            padding: 12px 30px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            font-size: 16px;
        }
        button:hover {
            background: #764ba2;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
        }
        .success {
            background: #d4edda;
            color: #155724;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
        }
        .preview {
            margin-top: 20px;
            text-align: center;
        }
        .preview img {
            max-width: 300px;
            border-radius: 10px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
    </style>
</head>
<body>

<h1>Upload zdjęcia profilowego</h1>

<?php if (!empty($errors)): ?>
    <div class="error">
        <strong>Błędy:</strong>
        <ul>
            <?php foreach ($errors as $error): ?>
                <li><?= htmlspecialchars($error) ?></li>
            <?php endforeach; ?>
        </ul>
    </div>
<?php endif; ?>

<?php if ($success): ?>
    <div class="success">
        Plik został przesłany pomyślnie!
        <div class="preview">
            <img src="<?= htmlspecialchars($uploaded_file_path) ?>" alt="Avatar">
        </div>
    </div>
<?php endif; ?>

<form method="POST" enctype="multipart/form-data" class="upload-form">
    <p>Wybierz zdjęcie (max 2MB, JPG/PNG/GIF):</p>
    <input type="file" name="avatar" accept="image/*" required>
    
    <button type="submit">Prześlij plik</button>
</form>

<p><small>Informacje o pliku:</small></p>
<?php if (isset($_FILES['avatar'])): ?>
    <pre><?php print_r($_FILES['avatar']); ?></pre>
<?php endif; ?>

</body>
</html>

WAŻNE: Formularz uploadu MUSI mieć enctype="multipart/form-data"!

Kody błędów $_FILES

KodStałaZnaczenie
0UPLOAD_ERR_OKSukces
1UPLOAD_ERR_INI_SIZEPlik większy niż upload_max_filesize w php.ini
2UPLOAD_ERR_FORM_SIZEPlik większy niż MAX_FILE_SIZE w formularzu
3UPLOAD_ERR_PARTIALPlik został przesłany tylko częściowo
4UPLOAD_ERR_NO_FILENie przesłano pliku
6UPLOAD_ERR_NO_TMP_DIRBrak folderu tymczasowego
7UPLOAD_ERR_CANT_WRITENie można zapisać pliku na dysku
8UPLOAD_ERR_EXTENSIONRozszerzenie PHP zablokowało upload

5. $_SESSION – Przechowywanie danych użytkownika

Sesje pozwalają przechowywać dane między różnymi stronami dla konkretnego użytkownika. Informacje dodatkowe w temacie PHP – obsługa cookies i sesje

<?php
// ZAWSZE na początku pliku!
session_start();

// Zapisywanie danych w sesji
$_SESSION['user_id'] = 123;
$_SESSION['username'] = 'ProGamer';
$_SESSION['logged_in'] = true;

// Odczytywanie danych z sesji
$user_id = $_SESSION['user_id'] ?? 0;
$username = $_SESSION['username'] ?? 'Gość';

// Sprawdzanie czy klucz istnieje
if (isset($_SESSION['logged_in'])) {
    echo "Jesteś zalogowany jako: {$username}";
} else {
    echo "Nie jesteś zalogowany";
}

// Usuwanie konkretnego klucza
unset($_SESSION['username']);

// Niszczenie całej sesji
session_destroy();
?>

System logowania z sesją

Plik: logowanie.php

<?php
session_start();

// Jeśli użytkownik już zalogowany - przekieruj
if (isset($_SESSION['logged_in']) && $_SESSION['logged_in'] === true) {
    header("Location: panel.php");
    exit;
}

mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');
$db->set_charset('utf8mb4');

$errors = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    $username = trim($_POST['username'] ?? '');
    $password = $_POST['password'] ?? '';
    
    if (empty($username)) {
        $errors[] = "Username jest wymagany";
    }
    
    if (empty($password)) {
        $errors[] = "Hasło jest wymagane";
    }
    
    if (empty($errors)) {
        
        $stmt = $db->prepare("SELECT id, username, email, password FROM users WHERE username = ?");
        $stmt->bind_param("s", $username);
        $stmt->execute();
        $wynik = $stmt->get_result();
        $user = $wynik->fetch_assoc();
        $stmt->close();
        
        if ($user && password_verify($password, $user['password'])) {
            
            // Zapisz dane w sesji
            $_SESSION['user_id'] = $user['id'];
            $_SESSION['username'] = $user['username'];
            $_SESSION['email'] = $user['email'];
            $_SESSION['logged_in'] = true;
            $_SESSION['login_time'] = time();
            
            $db->close();
            
            header("Location: panel.php");
            exit;
            
        } else {
            $errors[] = "Nieprawidłowy username lub hasło";
        }
    }
}

$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Logowanie</title>
</head>
<body>

<h1>Logowanie</h1>

<?php if (!empty($errors)): ?>
    <div style="background:#f8d7da; padding:15px; color:#721c24;">
        <ul>
            <?php foreach ($errors as $error): ?>
                <li><?= htmlspecialchars($error) ?></li>
            <?php endforeach; ?>
        </ul>
    </div>
<?php endif; ?>

<form method="POST">
    <label>Username:</label><br>
    <input type="text" name="username" value="<?= htmlspecialchars($_POST['username'] ?? '') ?>" required><br><br>
    
    <label>Hasło:</label><br>
    <input type="password" name="password" required><br><br>
    
    <button type="submit">Zaloguj</button>
</form>

</body>
</html>

Plik: panel.php

<?php
session_start();

// Sprawdź czy użytkownik zalogowany
if (!isset($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
    header("Location: logowanie.php");
    exit;
}

$username = htmlspecialchars($_SESSION['username']);
$user_id = $_SESSION['user_id'];
$login_time = $_SESSION['login_time'];

$time_elapsed = time() - $login_time;
$minutes = floor($time_elapsed / 60);
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Panel użytkownika</title>
</head>
<body>

<h1>Witaj, <?= $username ?>!</h1>

<p>Twoje ID: <?= $user_id ?></p>
<p>Zalogowany od: <?= $minutes ?> minut</p>

<p><a href="wyloguj.php">Wyloguj się</a></p>

<h2>Dane sesji:</h2>
<pre><?php print_r($_SESSION); ?></pre>

</body>
</html>

Plik: wyloguj.php

<?php
session_start();

// Usuń wszystkie dane sesji
$_SESSION = [];

// Usuń cookie sesyjne
if (isset($_COOKIE[session_name()])) {
    setcookie(session_name(), '', time() - 3600, '/');
}

// Zniszcz sesję
session_destroy();

// Przekieruj do logowania
header("Location: logowanie.php");
exit;
?>

6. $_COOKIE – Przechowywanie danych w przeglądarce

Cookies to małe pliki tekstowe przechowywane w przeglądarce użytkownika. Więcej w temacie PHP – obsługa cookies i sesje

Ustawianie cookies

<?php
// setcookie(nazwa, wartość, wygasa, ścieżka, domena, secure, httponly)

// Prosty cookie na 30 dni
setcookie('username', 'ProGamer', time() + (30 * 24 * 60 * 60));

// Bezpieczny cookie
setcookie('session_token', 'abc123xyz', [
    'expires' => time() + 3600,     // 1 godzina
    'path' => '/',                   // Dostępny w całej domenie
    'domain' => '',                  // Aktualna domena
    'secure' => true,                // Tylko HTTPS
    'httponly' => true,              // Niedostępny dla JavaScript
    'samesite' => 'Strict'           // Ochrona CSRF
]);
?>

UWAGA: setcookie() MUSI być wywołane PRZED jakimkolwiek output (echo, HTML)!

Odczytywanie cookies

<?php
// Sprawdzamy czy cookie istnieje
if (isset($_COOKIE['username'])) {
    $username = htmlspecialchars($_COOKIE['username']);
    echo "Witaj ponownie, {$username}!";
} else {
    echo "Witaj po raz pierwszy!";
}

// Operator null coalescing
$theme = $_COOKIE['theme'] ?? 'light';
echo "Aktywny motyw: {$theme}";
?>

Usuwanie cookies

<?php
// Ustaw datę wygaśnięcia w przeszłości
setcookie('username', '', time() - 3600);

// Lub
setcookie('username', '', 1);
?>

„Zapamiętaj mnie” przy logowaniu

<?php
session_start();

// Sprawdź czy istnieje cookie "remember_me"
if (!isset($_SESSION['logged_in']) && isset($_COOKIE['remember_token'])) {
    
    mysqli_report(MYSQLI_REPORT_OFF);
    $db = @new mysqli('localhost', 'root', '', 'gaming_hub');
    $db->set_charset('utf8mb4');
    
    $token = $_COOKIE['remember_token'];
    
    // Sprawdź token w bazie (w prawdziwej aplikacji przechowuj hash tokenu!)
    $stmt = $db->prepare("SELECT id, username, email FROM users WHERE remember_token = ?");
    $stmt->bind_param("s", $token);
    $stmt->execute();
    $wynik = $stmt->get_result();
    $user = $wynik->fetch_assoc();
    $stmt->close();
    $db->close();
    
    if ($user) {
        // Automatyczne logowanie
        $_SESSION['user_id'] = $user['id'];
        $_SESSION['username'] = $user['username'];
        $_SESSION['email'] = $user['email'];
        $_SESSION['logged_in'] = true;
    }
}

$errors = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    
    mysqli_report(MYSQLI_REPORT_OFF);
    $db = @new mysqli('localhost', 'root', '', 'gaming_hub');
    $db->set_charset('utf8mb4');
    
    $username = trim($_POST['username'] ?? '');
    $password = $_POST['password'] ?? '';
    $remember = isset($_POST['remember']) ? true : false;
    
    // Walidacja i sprawdzanie użytkownika...
    
    $stmt = $db->prepare("SELECT id, username, email, password FROM users WHERE username = ?");
    $stmt->bind_param("s", $username);
    $stmt->execute();
    $wynik = $stmt->get_result();
    $user = $wynik->fetch_assoc();
    $stmt->close();
    
    if ($user && password_verify($password, $user['password'])) {
        
        // Zapisz w sesji
        $_SESSION['user_id'] = $user['id'];
        $_SESSION['username'] = $user['username'];
        $_SESSION['logged_in'] = true;
        
        // Jeśli zaznaczono "Zapamiętaj mnie"
        if ($remember) {
            $token = bin2hex(random_bytes(32)); // Generuj bezpieczny token
            
            // Zapisz token w bazie
            $stmt_token = $db->prepare("UPDATE users SET remember_token = ? WHERE id = ?");
            $stmt_token->bind_param("si", $token, $user['id']);
            $stmt_token->execute();
            $stmt_token->close();
            
            // Ustaw cookie na 30 dni
            setcookie('remember_token', $token, [
                'expires' => time() + (30 * 24 * 60 * 60),
                'path' => '/',
                'secure' => true,
                'httponly' => true,
                'samesite' => 'Strict'
            ]);
        }
        
        $db->close();
        
        header("Location: panel.php");
        exit;
    } else {
        $errors[] = "Nieprawidłowy username lub hasło";
    }
    
    $db->close();
}
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Logowanie</title>
</head>
<body>

<h1>Logowanie</h1>

<form method="POST">
    <label>Username:</label><br>
    <input type="text" name="username" required><br><br>
    
    <label>Hasło:</label><br>
    <input type="password" name="password" required><br><br>
    
    <label>
        <input type="checkbox" name="remember">
        Zapamiętaj mnie (30 dni)
    </label><br><br>
    
    <button type="submit">Zaloguj</button>
</form>

</body>
</html>

7. $_SERVER – Informacje o serwerze i żądaniu

$_SERVER zawiera informacje o środowisku serwera, nagłówkach HTTP i żądaniu.

<?php
// Metoda HTTP
echo $_SERVER['REQUEST_METHOD']; // GET, POST, PUT, DELETE

// URL i ścieżki
echo $_SERVER['REQUEST_URI'];         // /profil.php?id=5
echo $_SERVER['PHP_SELF'];            // /profil.php
echo $_SERVER['SCRIPT_NAME'];         // /profil.php
echo $_SERVER['QUERY_STRING'];        // id=5&sort=level

// Informacje o kliencie
echo $_SERVER['REMOTE_ADDR'];         // Adres IP użytkownika (np. 192.168.1.1)
echo $_SERVER['HTTP_USER_AGENT'];     // Przeglądarka (np. Mozilla/5.0...)
echo $_SERVER['HTTP_REFERER'];        // Poprzednia strona (skąd przyszedł)

// Informacje o serwerze
echo $_SERVER['SERVER_NAME'];         // localhost
echo $_SERVER['SERVER_ADDR'];         // 127.0.0.1
echo $_SERVER['SERVER_PORT'];         // 80 lub 443
echo $_SERVER['DOCUMENT_ROOT'];       // C:/xampp/htdocs

// HTTPS
echo $_SERVER['HTTPS'] ?? 'off';      // on jeśli HTTPS

// Nagłówki HTTP
echo $_SERVER['HTTP_ACCEPT'];         // Akceptowane typy treści
echo $_SERVER['HTTP_ACCEPT_LANGUAGE']; // Preferowany język
?>

Wyświetlanie informacji o żądaniu

<?php
function getClientIP() {
    $ip = $_SERVER['REMOTE_ADDR'];
    
    // Sprawdź czy użyto proxy
    if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
        $ip = $_SERVER['HTTP_CLIENT_IP'];
    } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
    }
    
    return $ip;
}

function getBrowser() {
    $user_agent = $_SERVER['HTTP_USER_AGENT'];
    
    if (strpos($user_agent, 'Firefox') !== false) {
        return 'Mozilla Firefox';
    } elseif (strpos($user_agent, 'Chrome') !== false) {
        return 'Google Chrome';
    } elseif (strpos($user_agent, 'Safari') !== false) {
        return 'Safari';
    } elseif (strpos($user_agent, 'Edge') !== false) {
        return 'Microsoft Edge';
    } else {
        return 'Nieznana przeglądarka';
    }
}
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Informacje o żądaniu</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 800px;
            margin: 50px auto;
            padding: 20px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
        }
        th, td {
            padding: 12px;
            border: 1px solid #ddd;
            text-align: left;
        }
        th {
            background: #667eea;
            color: white;
        }
        tr:hover {
            background: #f5f5f5;
        }
    </style>
</head>
<body>

<h1>Informacje o Twoim żądaniu</h1>

<table>
    <tr>
        <th>Parametr</th>
        <th>Wartość</th>
    </tr>
    <tr>
        <td><strong>Twój adres IP:</strong></td>
        <td><?= htmlspecialchars(getClientIP()) ?></td>
    </tr>
    <tr>
        <td><strong>Przeglądarka:</strong></td>
        <td><?= htmlspecialchars(getBrowser()) ?></td>
    </tr>
    <tr>
        <td><strong>User Agent:</strong></td>
        <td><?= htmlspecialchars($_SERVER['HTTP_USER_AGENT']) ?></td>
    </tr>
    <tr>
        <td><strong>Metoda HTTP:</strong></td>
        <td><?= htmlspecialchars($_SERVER['REQUEST_METHOD']) ?></td>
    </tr>
    <tr>
        <td><strong>Request URI:</strong></td>
        <td><?= htmlspecialchars($_SERVER['REQUEST_URI']) ?></td>
    </tr>
    <tr>
        <td><strong>Query String:</strong></td>
        <td><?= htmlspecialchars($_SERVER['QUERY_STRING'] ?? 'brak') ?></td>
    </tr>
    <tr>
        <td><strong>Protokół:</strong></td>
        <td><?= htmlspecialchars($_SERVER['SERVER_PROTOCOL']) ?></td>
    </tr>
    <tr>
        <td><strong>HTTPS:</strong></td>
        <td><?= isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'TAK' : 'NIE' ?></td>
    </tr>
    <tr>
        <td><strong>Serwer:</strong></td>
        <td><?= htmlspecialchars($_SERVER['SERVER_NAME']) ?></td>
    </tr>
    <tr>
        <td><strong>Port:</strong></td>
        <td><?= htmlspecialchars($_SERVER['SERVER_PORT']) ?></td>
    </tr>
    <tr>
        <td><strong>Document Root:</strong></td>
        <td><?= htmlspecialchars($_SERVER['DOCUMENT_ROOT']) ?></td>
    </tr>
    <tr>
        <td><strong>Skrypt PHP:</strong></td>
        <td><?= htmlspecialchars($_SERVER['SCRIPT_NAME']) ?></td>
    </tr>
    <tr>
        <td><strong>Referer (poprzednia strona):</strong></td>
        <td><?= htmlspecialchars($_SERVER['HTTP_REFERER'] ?? 'brak') ?></td>
    </tr>
    <tr>
        <td><strong>Akceptowany język:</strong></td>
        <td><?= htmlspecialchars($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'brak') ?></td>
    </tr>
</table>

<h2>Wszystkie dane $_SERVER:</h2>
<pre><?php print_r($_SERVER); ?></pre>

</body>
</html>

$_ENV – Zmienne środowiskowe

$_ENV zawiera zmienne środowiskowe systemu operacyjnego. W PHP zwykle używane z plikami .env (przez biblioteki jak vlucas/phpdotenv).

<?php
// Odczytywanie zmiennej środowiskowej
$db_host = $_ENV['DB_HOST'] ?? 'localhost';
$db_user = $_ENV['DB_USER'] ?? 'root';
$db_pass = $_ENV['DB_PASSWORD'] ?? '';

// Lub za pomocą getenv()
$db_name = getenv('DB_NAME');

echo "Host: {$db_host}<br>";
echo "User: {$db_user}<br>";
?>

Przykład pliku .env:

DB_HOST=localhost
DB_USER=root
DB_PASSWORD=secret123
DB_NAME=gaming_hub
APP_ENV=development

9. $GLOBALS – Dostęp do zmiennych globalnych

$GLOBALS to tablica zawierająca wszystkie zmienne globalne.

<?php
$username = "ProGamer";
$level = 50;

function displayInfo() {
    // Dostęp do zmiennych globalnych przez $GLOBALS
    echo "Username: {$GLOBALS['username']}<br>";
    echo "Level: {$GLOBALS['level']}<br>";
}

displayInfo();

// Wyświetl wszystkie zmienne globalne
echo "<pre>";
print_r($GLOBALS);
echo "</pre>";
?>

UWAGA: Używanie $GLOBALS jest niezalecane – lepiej przekazywać zmienne jako parametry funkcji.

TaskQuest 2.1 — avatar gracza

KARTA PRACY

Po Lekcji 19 TaskQuest zapisuje w bazie zadania, XP i serię dni. Gracze różnią się jednak tylko loginem i liczbami. Dziś każdy gracz dostanie avatar: obrazek, który sam wyśle ze swojego komputera. Gracz bez avatara zobaczy kółko z pierwszą literą imienia.

Z tablicami $_GET, $_POST, $_SESSION i $_COOKIE już pracujesz. Wszystkie przenoszą tekst. Plik to coś innego: może mieć kilka megabajtów, może być czymkolwiek, a jego nazwę i typ podaje przeglądarka użytkownika. Dlatego PHP obsługuje pliki osobną tablicą superglobalną $_FILES. Tak jak pozostałe superglobalne, jest dostępna wszędzie, także wewnątrz funkcji, bez przekazywania jej w parametrze.

Lekcja 20 jest w tym kursie pomijana. Pracujesz na projekcie w stanie po Lekcji 19.

Cel lekcji

Nauczyć się:

  • budować formularz wysyłający plik (enctype="multipart/form-data", <input type="file">),
  • czytać tablicę $_FILES: nazwę, rozmiar, kod błędu i miejsce, w którym PHP tymczasowo trzyma plik,
  • rozpoznawać kody błędów przesyłania (UPLOAD_ERR_...),
  • sprawdzać plik przed zapisem: kod błędu, rozmiar, rozszerzenie z listy dozwolonych i zawartość (getimagesize()),
  • zapisywać plik na serwerze pod własną nazwą (move_uploaded_file()), a w bazie przechowywać tylko tę nazwę.

Efekt: nowa strona profil.php z formularzem avatara. Po wysłaniu poprawnego obrazka gracz wraca na listę zadań i widzi swój avatar obok powitania. Plik zły, za duży albo niebędący obrazkiem jest odrzucany z czytelnym komunikatem.

Uwaga — co jest, a czego jeszcze nie ma:

  • Stary plik avatara zostaje na dysku po zmianie na nowy. To świadome uproszczenie; sprzątanie jest zadaniem końcowym A.
  • Nie ma przycisku „Usuń avatar” (zadanie końcowe B) ani avatarów w rankingu (zadanie końcowe C).
  • Katalog z avatarami dostanie dodatkowe zabezpieczenie po stronie serwera w lekcji o bezpieczeństwie (22). Dziś chronimy go walidacją w PHP.

Rozgrzewka — 4 pytania z Lekcji 19

  1. Dlaczego „Zrobione!” jest przyciskiem w formularzu POST, a nie linkiem oznacz-wykonane.php?id=5?
  2. UPDATE zadania SET data_wykonania = CURDATE() WHERE id = ? AND uzytkownik_id = ? zwrócił affected_rows równe 0. Podaj dwie różne przyczyny.
  3. Dlaczego XP gracza zwiększamy zapytaniem xp = xp + ?, a nie zapisujemy liczby policzonej w PHP?
  4. Po udanym dodaniu zadania dodaj-zadanie.php wywołuje header('Location: index.php'). Co by się stało, gdyby tego przekierowania nie było, a gracz odświeżył stronę?

1. Wracamy do naszego TaskQuest

Co zostaje, co dochodzi, co zmieniamy

ElementPo Lekcji 19Po Lekcji 21
tabela uzytkownicy (sql/taskquest.sql)bez avataradochodzi kolumna avatar (nazwa pliku albo NULL)
klasa Uzytkownikid, login, imię, XP, streak, data aktywnościdochodzi właściwość avatar i metoda pobierzAvatar()
wczytajUzytkownika()czyta 6 kolumnzmiana: czyta też avatar
includes/funkcje_bazy.php10 funkcjidochodzi zapiszAvatarUzytkownika()
includes/funkcje_avatara.phpnie manowy plik: rozszerzenieAvatara(), walidujAvatar()
config/config.phpstałe XP, widoku, bazydochodzą 3 stałe avatara
profil.phpnie manowy plik: formularz avatara
katalog uploads/avatary/nie manowy katalog na pliki avatarów
index.phppowitanie bez obrazkazmiana: avatar albo pierwsza litera imienia, link do profilu
logowanie, zadania, „Zrobione!”, usuwanie, rankingdziałajązostają bez zmian

Schemat strony z formularzem znasz z dodaj-zadanie.php (Lekcja 13): logika na górze, tablica $bledy, walidacja w funkcji, zapis w try, komunikat jednorazowy, przekierowanie. profil.php zbudujemy dokładnie tak samo. Nowe jest tylko to, skąd przychodzą dane i co trzeba z nimi zrobić.

Docelowa struktura projektu

taskquest/
├── index.php                        (zmiana: avatar przy powitaniu, link do profilu)
├── profil.php                       NOWY
├── dodaj-zadanie.php                bez zmian
├── oznacz-wykonane.php              bez zmian
├── usun-zadanie.php                 bez zmian
├── zaloguj.php                      bez zmian
├── wyloguj.php                      bez zmian
├── diagnostyka.php                  (zmiana: 21 ścieżek, sekcja „Avatary”)
├── classes/
│   ├── Zadanie.php                  bez zmian
│   └── Uzytkownik.php               (zmiana: avatar)
├── config/
│   └── config.php                   (zmiana: stałe avatara)
├── includes/
│   ├── header.php                   (zmiana: style .avatar)
│   ├── footer.php                   (zmiana: „TaskQuest 2.1”)
│   ├── funkcje_avatara.php          NOWY
│   ├── funkcje_bazy.php             (zmiana: wczytajUzytkownika(), zapiszAvatarUzytkownika())
│   └── ... pozostałe                bez zmian
├── sql/
│   └── taskquest.sql                (zmiana: kolumna avatar)
└── uploads/                         NOWY
    └── avatary/                     NOWY (pusty — pliki wysyłają gracze)

Przygotowanie (zrób to teraz)

  1. Zrób kopię zapasową katalogu taskquest/ jako taskquest_lekcja19/ (obok, nie w środku).
  2. Uruchom w XAMPP Apache i MySQL.
  3. Przygotuj w jednym miejscu (np. na pulpicie, w katalogu pliki_testowe) pięć plików. Będą potrzebne w brudnopisie i w testach:
PlikJak go zdobyć
kot.png (albo .jpg)dowolny mały obrazek, poniżej 1 MB, np. zrzut fragmentu ekranu
duzy.jpgzdjęcie z telefonu albo tapeta, powyżej 1 MB (sprawdź we właściwościach pliku)
notatka.pngutwórz plik tekstowy, wpisz w nim kilka słów, zapisz i zmień rozszerzenie z .txt na .png (w Eksploratorze Windows włącz wcześniej „Rozszerzenia nazw plików”)
pusty.pngutwórz pusty plik tekstowy (nic nie wpisuj) i zmień rozszerzenie na .png
dokument.pdfdowolny plik PDF

2. Problem w aplikacji

Kama chce, żeby koledzy w rankingu i na liście od razu ją rozpoznawali. Chce wstawić swój obrazek, zapisany na dysku jako C:\Users\Kama\Pictures\kot.png.

Formularze, które mamy, przesyłają tylko tekst: login, tytuł, termin, numer zadania. Gdyby wysłać sam napis kot.png, serwer dostałby nazwę pliku, ale nie jego zawartość. Obrazek zostałby na komputerze Kamy.

Aplikacja musi więc:

  1. odebrać plik z komputera gracza,
  2. sprawdzić go. Plik mógł się nie dojść. Może być za duży albo może nie być obrazkiem. Może też być skryptem PHP z nazwą udającą obrazek,
  3. zapisać go na serwerze w katalogu aplikacji,
  4. zapamiętać w bazie, który plik należy do którego gracza,
  5. pokazać go na stronie.

Punkt 2 jest najważniejszy. Do tej pory gracz wysyłał tekst, który co najwyżej trafiał do bazy. Teraz wysyła plik, który zostanie zapisany na naszym serwerze. Jeśli przepuścimy plik atak.php, a ktoś otworzy go w przeglądarce, serwer go wykona.

3. Nowy mechanizm: $_FILES

3.1 Formularz z plikiem

Pole wyboru pliku to <input type="file">. Samo pole nie wystarczy. Formularz musi też mieć dwa atrybuty:

<form method="post" action="brudnopis.php" enctype="multipart/form-data">
    <input type="file" name="avatar">
    <button type="submit">Wyślij</button>
</form>
  • method="post" — pliku nie da się wysłać w adresie strony (GET),
  • enctype="multipart/form-data" — mówi przeglądarce, żeby wysłała dane w częściach: osobno każde pole tekstowe i osobno surową zawartość pliku. Bez tego atrybutu przeglądarka wyśle tylko nazwę wybranego pliku, jako zwykły tekst (sprawdzisz to w Eksperymencie 1).

Atrybut accept=".jpg,.jpeg,.png,.webp" sprawia, że okno wyboru pliku pokaże tylko pliki o tych rozszerzeniach. Tak jak required czy maxlength z Lekcji 13, to wygoda, nie ochrona. Użytkownik zmieni filtr w oknie na „Wszystkie pliki” albo wyśle żądanie bez formularza.

3.2 Co PHP robi z przesłanym plikiem

Kiedy żądanie z plikiem dociera na serwer, PHP zanim uruchomi Twój skrypt:

  1. zapisuje zawartość pliku w katalogu tymczasowym (w XAMPP np. C:\xampp\tmp\) pod losową nazwą, np. php3A1F.tmp,
  2. wpisuje informacje o pliku do tablicy $_FILES, pod nazwą pola z formularza (name="avatar" → $_FILES['avatar']).

$_FILES['avatar'] to tablica asocjacyjna z pięcioma kluczami:

$_FILES['avatar'] = [
    'name'     => 'kot.png',                    // nazwa pliku na komputerze użytkownika
    'type'     => 'image/png',                  // typ pliku podany przez przeglądarkę
    'tmp_name' => 'C:\xampp\tmp\php3A1F.tmp',   // gdzie PHP zapisał plik tymczasowo
    'error'    => 0,                            // kod błędu przesyłania (0 = bez błędu)
    'size'     => 48213,                        // rozmiar w bajtach
];

(Od PHP 8.1 jest jeszcze szósty klucz, full_path. Nie będzie nam potrzebny.)

Najważniejsza zasada: plik tymczasowy istnieje tylko do końca skryptu. Po ostatniej linii PHP go usuwa. Jeśli skrypt nie przeniesie pliku w stałe miejsce, plik przepada.

3.3 Kody błędów

$_FILES['avatar']['error'] mówi, czy przesyłanie się udało. Zamiast liczb używamy stałych PHP:

StałaWartośćCo się stało
UPLOAD_ERR_OK0plik doszedł cały
UPLOAD_ERR_INI_SIZE1plik jest większy niż limit serwera (upload_max_filesize w php.ini)
UPLOAD_ERR_FORM_SIZE2plik jest większy niż limit zapisany w formularzu (MAX_FILE_SIZE, nie używamy go)
UPLOAD_ERR_PARTIAL3przesyłanie się urwało, doszła tylko część pliku
UPLOAD_ERR_NO_FILE4użytkownik nie wybrał pliku
UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE, UPLOAD_ERR_EXTENSION6, 7, 8problem po stronie serwera

Kiedy error jest inny niż UPLOAD_ERR_OK, pozostałe klucze nie mają znaczenia: tmp_name jest pusty, a size wynosi 0.

3.4 Zapis pliku: move_uploaded_file()

move_uploaded_file($_FILES['avatar']['tmp_name'], 'uploads/moj_plik.png');

Funkcja przenosi plik z katalogu tymczasowego w podane miejsce. Zwraca true albo false, na przykład gdy katalog docelowy nie istnieje.

Dlaczego nie rename() czy copy(), które znasz z pracy z plikami? move_uploaded_file() najpierw sprawdza, czy podana ścieżka to naprawdę plik przesłany w tym żądaniu. Dzięki temu nie da się jej podsunąć np. config/config.php i skopiować go do katalogu dostępnego z przeglądarki.

3.5 Któremu kluczowi wierzyć?

KluczKto go ustalaWierzymy?
nameprzeglądarka użytkownikanie — może to być dowolny tekst: kot.png.php, index.php, nazwa z polskimi znakami, nazwa taka sama jak u innego gracza
typeprzeglądarka użytkownikanie — przeglądarka zgaduje typ z rozszerzenia; atakujący wpisze tu, co chce
sizePHP (policzył bajty)tak
errorPHPtak
tmp_namePHPtak

Wniosek dla TaskQuest:

  • z nazwy pliku bierzemy tylko rozszerzenie, żeby sprawdzić je z listą dozwolonych. Plik zapisujemy pod własną nazwą,
  • klucza type nie używamy wcale. Czy plik to obrazek, sprawdzamy sami, zaglądając do jego zawartości funkcją getimagesize().

getimagesize($sciezka) otwiera plik i próbuje odczytać nagłówek obrazka. Dla obrazka zwraca tablicę (szerokość, wysokość, typ). Dla pliku, który obrazkiem nie jest, zwraca false:

var_dump(getimagesize('kot.png'));      // array(...) — [0] => szerokość, [1] => wysokość
var_dump(getimagesize('notatka.png'));  // bool(false) — to tekst, choć ma końcówkę .png

Pusty plik (0 bajtów) to przypadek szczególny. getimagesize() zwraca wtedy false, ale dodatkowo wypisuje Notice: ... Error reading from .... Dlatego pusty plik odrzucimy wcześniej, zanim zajrzymy do zawartości.

3.6 Limity serwera

W php.ini są dwa ustawienia, które decydują, co w ogóle dotrze do skryptu:

  • upload_max_filesize — największy pojedynczy plik. Większy → error = UPLOAD_ERR_INI_SIZE,
  • post_max_size — największe całe żądanie POST (plik + pozostałe pola). Większe → PHP odrzuca całe żądanie: $_POST i $_FILES są puste, choć formularz został wysłany metodą POST.

Drugi przypadek jest podchwytliwy. $_FILES['avatar'] wtedy nie istnieje, więc skrypt, który od razu czyta $_FILES['avatar']['error'], dostanie ostrzeżenie o braku klucza. Odczyt przez ?? null rozwiązuje problem, tak samo jak ?? '' przy polach tekstowych.

W TaskQuest ustawimy własny limit 1 MB, niższy niż limity serwera. Dzięki temu zwykle to nasza walidacja zgłosi „za duży plik” z czytelnym komunikatem, zanim zadziała limit serwera.

Typowe błędy

  • brak enctype="multipart/form-data" — $_FILES jest puste, a w $_POST['avatar'] jest sama nazwa pliku,
  • zapis pod nazwą z $_FILES['avatar']['name'] — dwaj gracze z plikiem avatar.png nadpisują sobie obrazki (Eksperyment 3), a nazwa atak.php zamienia avatar w skrypt,
  • sprawdzanie typu przez $_FILES['avatar']['type'] — ten klucz podaje przeglądarka,
  • brak move_uploaded_file() — skrypt „zapisał” avatar w bazie, ale sam plik zniknął razem z plikiem tymczasowym,
  • odczyt $_FILES['avatar']['error'] bez sprawdzenia, czy $_FILES['avatar'] istnieje.

4. SAMODZIELNIE — pierwszy przesłany plik

Utwórz w katalogu taskquest/ plik brudnopis.php. Napisz w nim:

  1. formularz wysyłający jeden plik z pola o nazwie avatar do tego samego pliku (brudnopis.php),
  2. nad formularzem kod PHP, który tylko po wysłaniu formularza wypisze całą zawartość $_FILES w czytelnej postaci.

Wyślij kot.png. Potem wyślij formularz bez wybierania pliku. Porównaj oba wyniki z tabelą kodów błędów.

HINT

Jak w dodaj-zadanie.php sprawdzasz, że formularz został wysłany? Do czytelnego wypisania tablicy użyj funkcji, którą znasz z pracy z tablicami (wynik wstaw między znaczniki <pre>).

POMOC
<?php

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    echo '<pre>';
    // wypisz $_FILES
    echo '</pre>';
}

?>
<form method="post" action="brudnopis.php" enctype="...">
    <!-- pole pliku o nazwie avatar i przycisk -->
</form>
ROZWIĄZANIE
<?php

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    echo '<pre>';
    print_r($_FILES);
    echo '</pre>';
}

?>
<form method="post" action="brudnopis.php" enctype="multipart/form-data">
    <input type="file" name="avatar">
    <button type="submit">Wyślij</button>
</form>

Oczekiwane po wysłaniu kot.png: tablica z kluczem avatar, w nim name = kot.png, type = image/png, tmp_name = ścieżka w katalogu tymczasowym XAMPP, error = 0, size = rozmiar pliku w bajtach (porównaj z właściwościami pliku w Windows).

Oczekiwane bez pliku: name pusty, tmp_name pusty, error = 4 (UPLOAD_ERR_NO_FILE), size = 0. Klucz avatar istnieje, bo pole było w formularzu.

Druga część — zapis. Utwórz obok brudnopisu katalog test_upload/. Pod linią z print_r() dopisz przeniesienie pliku do test_upload/ pod nazwą proba.png i wypisz wynik funkcji (var_dump()). Wyślij kot.png i zajrzyj do katalogu.

    var_dump(move_uploaded_file($_FILES['avatar']['tmp_name'], __DIR__ . '/test_upload/proba.png'));

Oczekiwane: bool(true) i plik proba.png w test_upload/. Otwórz w przeglądarce http://localhost/taskquest/test_upload/proba.png. Widzisz obrazek, więc wszystko, co trafi do tego katalogu, jest dostępne z przeglądarki. Zapamiętaj to, wrócimy do tego w Eksperymencie 2.

Wyślij teraz notatka.png. Znów bool(true): move_uploaded_file() przenosi każdy plik. Sprawdzanie, co to za plik, to nasze zadanie.

Wyślij formularz bez pliku. Wynik: bool(false), bo nie ma czego przenosić.

Trzecia część — limity Twojego serwera. Dopisz na końcu części PHP (poza warunkiem POST):

echo 'upload_max_filesize: ' . ini_get('upload_max_filesize') . '<br>';
echo 'post_max_size: ' . ini_get('post_max_size') . '<br>';

ini_get() odczytuje ustawienie z php.ini. Zapisz oba wyniki. Porównaj je z limitem 1 MB, który ustawimy w TaskQuest, i odpowiedz sobie: który limit zadziała pierwszy przy pliku 3 MB?

Katalog test_upload/ usuń po skończeniu. Brudnopis zostaw, przyda się w kroku 4.

5. Włączamy avatar do TaskQuest

Od tego miejsca pracujesz na projekcie. brudnopis.php zostaje tylko do testowania funkcji.

Plan: najpierw miejsce na dane (baza, klasa, stałe, katalog — kroki 1–3), potem funkcje (kroki 4–5), potem strona profil.php (krok 6) i diagnostyka (krok 7). Wyświetlenie avatara na stronie głównej to zadanie z sekcji 6. Po każdym kroku aplikacja działa.

Krok 1 — kolumna avatar w bazie

Otwórz sql/taskquest.sql. W CREATE TABLE uzytkownicy, pod linią data_ostatniej_aktywnosci DATE NULL, dopisz nową kolumnę. Nie zapomnij postawić przecinka na końcu linii data_ostatniej_aktywnosci, bo teraz nie jest już ostatnia:

    data_ostatniej_aktywnosci DATE NULL,       -- NULL = brak aktywności
    avatar VARCHAR(100) NULL                   -- (L21) nazwa pliku w uploads/avatary; NULL = brak avatara

Decyzje, które za tym stoją:

  • W bazie trzymamy samą nazwę pliku (np. avatar_1_1790000000.png), a nie ścieżkę uploads/avatary/... ani obrazek. Katalog zapiszemy raz, w stałej w config.php. Jeśli kiedyś go przeniesiemy, zmienimy jedną linię kodu, a nie wszystkie wiersze w bazie. Samego obrazka nie trzymamy w bazie, bo pliki przechowuje system plików, a baza przechowuje informację, który plik do kogo należy.
  • NULL = gracz nie ma avatara. To ta sama zasada co przy data_wykonania i data_ostatniej_aktywnosci.
  • VARCHAR(100) wystarczy z zapasem, bo nazwę pliku tworzymy sami i ma około 30 znaków.

Sprawdź w tym samym pliku polecenie INSERT INTO uzytkownicy. Jeśli wymienia kolumny w nawiasie (INSERT INTO uzytkownicy (login, imie, ...) VALUES ...), nie zmieniasz nic: kolumna, której nie wymieniono, dostanie NULL. Jeśli INSERT nie ma listy kolumn, dopisz na końcu każdego wiersza wartości , NULL.

Uruchom cały skrypt w phpMyAdmin (baza taskquest → SQL). Zajrzyj do tabeli uzytkownicy: Kama i Ola mają w kolumnie avatar wartość NULL.

Odśwież TaskQuest. Działa jak po Lekcji 19. Nowa kolumna niczego nie psuje, bo wczytajUzytkownika() wymienia kolumny w SELECT (bez *), więc jej nie widzi.

Krok 2 — SAMODZIELNIE: Uzytkownik zna swój avatar

2a. Klasa. Otwórz classes/Uzytkownik.php. Dodaj właściwość avatar według zasad z Lekcji 15:

  1. pod właściwością dataOstatniejAktywnosci nowa właściwość prywatna avatar. Jaki typ, skoro kolumna może być NULL? (Podpowiedź: tak samo jak dataOstatniejAktywnosci od Lekcji 18.)
  2. konstruktor: nowy ostatni parametr $avatar tego samego typu i linia, która przepisuje go do właściwości,
  3. pod metodą pobierzStreak() getter pobierzAvatar().

2b. Funkcja. W includes/funkcje_bazy.php, w funkcji wczytajUzytkownika():

  1. dopisz kolumnę avatar na końcu listy w SELECT,
  2. dopisz argument nazwany avatar: na końcu wywołania new Uzytkownik(...).

Sprawdzenie: w brudnopisie (pod całym dotychczasowym kodem albo w nowym pliku brudnopis2.php):

require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/classes/Uzytkownik.php';
require_once __DIR__ . '/includes/funkcje_bazy.php';

$polaczenie = polaczZBaza();
var_dump(wczytajUzytkownika($polaczenie, 1)->pobierzAvatar());

Oczekiwane: NULL. W phpMyAdmin wpisz Kamie w kolumnę avatar tekst test.png i odśwież brudnopis: string(8) "test.png". Przywróć NULL (w phpMyAdmin zaznacz pole „Null” przy edycji wiersza).

HINT

Typ, który dopuszcza tekst albo null, zapisujesz ze znakiem zapytania przed nazwą typu. Getter zwraca dokładnie ten typ. Jeśli po zmianie klasy strona pokazuje błąd ArgumentCountError albo Unknown named parameter, porównaj nazwę parametru w konstruktorze z nazwą argumentu w wczytajUzytkownika().

ROZWIĄZANIE

W klasie Uzytkownik:

    private ?string $dataOstatniejAktywnosci;
    private ?string $avatar;

Konstruktor (pozostałe linie ciała bez zmian):

    public function __construct(
        int $id,
        string $login,
        string $imieUzytkownika,
        int $xp,
        int $streak,
        ?string $dataOstatniejAktywnosci,
        ?string $avatar
    ) {
        // ... dotychczasowe przypisania ...
        $this->avatar = $avatar;
    }

Jeśli Twój konstruktor ma parametry w jednej linii, dopisz ?string $avatar na jej końcu po przecinku. Zapis w wielu liniach to tylko kwestia czytelności.

Getter pod pobierzStreak():

    public function pobierzAvatar(): ?string
    {
        return $this->avatar;
    }

W wczytajUzytkownika():

    $zapytanie = $polaczenie->prepare(
        'SELECT id, login, imie, xp, streak, data_ostatniej_aktywnosci, avatar FROM uzytkownicy WHERE id = ?'
    );

oraz na końcu tworzenia obiektu:

        dataOstatniejAktywnosci: $wiersz['data_ostatniej_aktywnosci'],
        avatar: $wiersz['avatar'],
    );

Nie ma settera ustawAvatar(). Avatar zmienia się w bazie, a obiekt żyje tylko przez jedno żądanie (Lekcja 15). Po zmianie avatara strona i tak zostanie wczytana od nowa.

Krok 3 — stałe i katalog

3a. Katalog. W katalogu taskquest/ utwórz katalog uploads, a w nim avatary. Aplikacja nie tworzy go sama. Tak jak sql/, classes/ i config/, jest częścią struktury projektu, a jego obecność sprawdzi diagnostyka.php (krok 7).

3b. Stałe. Na końcu config/config.php dopisz:

// (L21) avatary graczy
define('KATALOG_AVATAROW', 'uploads/avatary');          // względem katalogu projektu
define('MAKS_ROZMIAR_AVATARA', 1024 * 1024);           // 1 MB w bajtach
define('DOZWOLONE_ROZSZERZENIA_AVATARA', ['jpg', 'jpeg', 'png', 'webp']);
  • KATALOG_AVATAROW jest ścieżką względną, bez ukośnika na końcu. Użyjemy jej w dwóch miejscach. Przy zapisie pliku doklejamy przed nią katalog projektu na dysku (__DIR__ . '/' . KATALOG_AVATAROW). W <img src="..."> używamy jej tak, jak jest: przeglądarka liczy ją względem adresu strony, a wszystkie strony TaskQuest leżą w katalogu głównym projektu.
  • 1024 * 1024: 1 KB to 1024 bajty, 1 MB to 1024 KB. Zapis mnożenia jest czytelniejszy niż 1048576.
  • Stała może przechowywać tablicę. Lista dozwolonych rozszerzeń jest w jednym miejscu. Dopisanie gif to zmiana jednej linii.

Rozszerzenia piszemy małymi literami. Za chwilę zobaczysz dlaczego.

Krok 4 — SAMODZIELNIE: funkcje avatara

Utwórz plik includes/funkcje_avatara.php (bez zamykającego ?>). Napiszemy w nim dwie funkcje.

4a. rozszerzenieAvatara() — gotowa. Wklej:

<?php

// Funkcje TaskQuest do obsługi avatarów (Lekcja 21).
// Wymagają wcześniejszego dołączenia config/config.php (stałe ..._AVATARA).

function rozszerzenieAvatara(string $nazwaPliku): string
{
    return strtolower(pathinfo($nazwaPliku, PATHINFO_EXTENSION));
}

pathinfo() znasz z listaPlikowWKatalogu() (Lekcja 10). Z drugim argumentem PATHINFO_EXTENSION zwraca samo rozszerzenie: dla kot.png → png, dla kot.png.php → php (liczy się ostatnia kropka), dla kot → pusty tekst. strtolower() zamienia litery na małe: plik KOT.PNG z aparatu też ma przejść.

4b. walidujAvatar() — napisz sama/sam. Nagłówek:

function walidujAvatar(array $plik): array

Funkcja dostaje jedną tablicę z $_FILES (np. $_FILES['avatar']) i zwraca tablicę komunikatów. [] oznacza, że plik jest poprawny. Tak samo działa walidujNoweZadanie(): nie rzuca wyjątków, niczego nie wyświetla, a komunikaty nie zawierają danych od użytkownika (nie powtarzamy nazwy pliku).

Reguły i komunikaty, w tej kolejności:

SytuacjaKomunikatCo dalej
error = brak plikuWybierz plik z obrazkiem.zwróć od razu
error = przekroczony limit serwera albo formularzaPlik może mieć najwyżej 1 MB.zwróć od razu
error = każdy inny błądNie udało się przesłać pliku. Spróbuj ponownie.zwróć od razu
size = 0Wybrany plik jest pusty.zwróć od razu
size większy niż MAKS_ROZMIAR_AVATARAPlik może mieć najwyżej 1 MB.sprawdzaj dalej
rozszerzenie spoza DOZWOLONE_ROZSZERZENIA_AVATARADozwolone formaty: JPG, PNG, WEBP.—
rozszerzenie dozwolone, ale zawartość to nie obrazekWybrany plik nie jest obrazkiem.—

Dlaczego czasem „zwróć od razu”? Kiedy przesyłanie się nie udało, nie ma pliku, więc nie ma czego sprawdzać dalej (tmp_name jest pusty). Pusty plik też kończy sprawdzanie, bo getimagesize() wypisałaby dla niego Notice (sekcja 3.5). Rozmiar i rozszerzenie to dwa niezależne problemy, więc pokazujemy oba naraz, jak w formularzu zadania. Zawartość sprawdzamy tylko przy dozwolonym rozszerzeniu: plik dokument.pdf ma dostać jeden komunikat o formacie, a nie dwa.

HINT
  • Porównuj $plik['error'] ze stałymi UPLOAD_ERR_... z sekcji 3.3, nie z liczbami.
  • Do sprawdzenia, czy wartość jest w tablicy, służy in_array($wartosc, $tablica, true). Trzeci argument true włącza porównanie ścisłe (===), tak jak zawsze w tym kursie.
  • Ostatnie dwie reguły to jedna konstrukcja if / elseif.
POMOC
function walidujAvatar(array $plik): array
{
    $bledy = [];

    if ($plik['error'] === UPLOAD_ERR_NO_FILE) {
        $bledy[] = 'Wybierz plik z obrazkiem.';
        return $bledy;
    }

    // ... dwa kolejne warunki z kodem błędu ...

    // ... pusty plik ...

    if ($plik['size'] > MAKS_ROZMIAR_AVATARA) {
        // ...
    }

    if (!in_array(rozszerzenieAvatara($plik['name']), DOZWOLONE_ROZSZERZENIA_AVATARA, true)) {
        // ...
    } elseif (getimagesize($plik['tmp_name']) === false) {
        // ...
    }

    return $bledy;
}
ROZWIĄZANIE
function walidujAvatar(array $plik): array
{
    $bledy = [];

    if ($plik['error'] === UPLOAD_ERR_NO_FILE) {
        $bledy[] = 'Wybierz plik z obrazkiem.';
        return $bledy;
    }

    if ($plik['error'] === UPLOAD_ERR_INI_SIZE || $plik['error'] === UPLOAD_ERR_FORM_SIZE) {
        $bledy[] = 'Plik może mieć najwyżej 1 MB.';
        return $bledy;
    }

    if ($plik['error'] !== UPLOAD_ERR_OK) {
        $bledy[] = 'Nie udało się przesłać pliku. Spróbuj ponownie.';
        return $bledy;
    }

    if ($plik['size'] === 0) {
        $bledy[] = 'Wybrany plik jest pusty.';
        return $bledy;
    }

    if ($plik['size'] > MAKS_ROZMIAR_AVATARA) {
        $bledy[] = 'Plik może mieć najwyżej 1 MB.';
    }

    if (!in_array(rozszerzenieAvatara($plik['name']), DOZWOLONE_ROZSZERZENIA_AVATARA, true)) {
        $bledy[] = 'Dozwolone formaty: JPG, PNG, WEBP.';
    } elseif (getimagesize($plik['tmp_name']) === false) {
        $bledy[] = 'Wybrany plik nie jest obrazkiem.';
    }

    return $bledy;
}

Przy błędzie limitu serwera (UPLOAD_ERR_INI_SIZE) piszemy „1 MB”, choć limit serwera jest wyższy. Użytkownika interesuje jedna zasada TaskQuest, a nie ustawienia php.ini.

4c. Test funkcji w brudnopisie. Funkcja przyjmuje zwykłą tablicę, więc możesz ją przetestować bez formularza, podając tablice ułożone jak $_FILES. Skopiuj kot.png i notatka.png do katalogu taskquest/, a w nowym brudnopis2.php (albo pod kodem z kroku 2) napisz:

require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/includes/funkcje_avatara.php';

$przypadki = [
    ['name' => 'kot.png', 'tmp_name' => __DIR__ . '/kot.png', 'error' => UPLOAD_ERR_OK, 'size' => 5000],
    // ... dopisz kolejne przypadki z tabeli poniżej ...
];

foreach ($przypadki as $plik) {
    echo $plik['name'] . ': ';
    print_r(walidujAvatar($plik));
    echo '<br>';
}

Klucza type nie podajemy, bo funkcja go nie czyta. Rozmiar size wpisujemy sami, żeby sprawdzić granice limitu.

Przewidź wynik, zanim uruchomisz:

nametmp_nameerrorsizeOczekiwane
kot.pngkot.pngUPLOAD_ERR_OK5000[]
KOT.PNGkot.pngUPLOAD_ERR_OK5000[]
kotkot.pngUPLOAD_ERR_OK5000format
kot.gifkot.pngUPLOAD_ERR_OK5000format
kot.png.phpkot.pngUPLOAD_ERR_OK5000format
notatka.pngnotatka.pngUPLOAD_ERR_OK20„nie jest obrazkiem”
atak.php.pngnotatka.pngUPLOAD_ERR_OK20„nie jest obrazkiem”
kot.pngkot.pngUPLOAD_ERR_OK1048576[] (dokładnie 1 MB — jeszcze wolno)
kot.pngkot.pngUPLOAD_ERR_OK1048577rozmiar
duzy.gifkot.pngUPLOAD_ERR_OK2000000rozmiar i format (dwa komunikaty)
pusty.pngkot.pngUPLOAD_ERR_OK0„pusty”
(pusty tekst)(pusty tekst)UPLOAD_ERR_NO_FILE0„Wybierz plik”
x.png(pusty tekst)UPLOAD_ERR_INI_SIZE0rozmiar
x.png(pusty tekst)UPLOAD_ERR_PARTIAL0„Nie udało się przesłać”

Zwróć uwagę na atak.php.png: rozszerzenie png przechodzi, ale zawartość (tekst) zatrzymuje getimagesize().

Krok 5 — SAMODZIELNIE: zapiszAvatarUzytkownika()

W includes/funkcje_bazy.php, na końcu pliku, napisz funkcję:

function zapiszAvatarUzytkownika(mysqli $polaczenie, int $uzytkownikId, string $nazwaPlikuAvatara): void

Ma wpisać nazwę pliku w kolumnę avatar wiersza gracza o podanym id. Zasady jak w Lekcji 19: prepared statement, niczego nie wyświetla, wyjątków nie łapie. Popraw komentarz na górze pliku: „(Lekcje 18–21)”.

HINT

To UPDATE jednego wiersza tabeli uzytkownicy, podobne do zapiszPostepUzytkownika(), tylko prostsze. Jaki warunek w WHERE sprawia, że zmieni się dokładnie jeden gracz?

ROZWIĄZANIE
function zapiszAvatarUzytkownika(mysqli $polaczenie, int $uzytkownikId, string $nazwaPlikuAvatara): void
{
    $zapytanie = $polaczenie->prepare('UPDATE uzytkownicy SET avatar = ? WHERE id = ?');
    $zapytanie->bind_param('si', $nazwaPlikuAvatara, $uzytkownikId);
    $zapytanie->execute();
}

Funkcja nie sprawdza affected_rows. id pochodzi z sesji (zaufane), a nazwa pliku jest za każdym razem inna, więc zmiana zawsze obejmie jeden wiersz. Dlaczego „za każdym razem inna”, zobaczysz w kroku 6c.

Sprawdzenie w brudnopisie: zapiszAvatarUzytkownika($polaczenie, 2, 'test.png'); → w phpMyAdmin Ola ma test.png. Uruchom ponownie sql/taskquest.sql.

Krok 6 — strona profil.php

Budujemy ją według schematu strony z formularzem (Lekcja 13), który znasz z dodaj-zadanie.php. Cały plik znajdziesz w sekcji 9. Zajrzyj tam dopiero po skończeniu.

6a. Szkielet strony

Utwórz profil.php. Na górze:

  1. session_start();,
  2. require_once plików: config/config.php, includes/funkcje_avatara.php, includes/funkcje_sesji.php, includes/funkcje_bazy.php,
  3. wymagajLogowania();,
  4. wartości początkowe: $bledy = []; i $bladZapisu = null;,
  5. pusty na razie warunek if ($_SERVER['REQUEST_METHOD'] === 'POST') { },
  6. $nazwaStrony = 'Profil — TaskQuest'; i dołączenie header.php.

Pod częścią PHP część HTML:

?>

<h2>Twój profil</h2>
<p><a href="index.php">← Wróć do listy zadań</a></p>

<?php if ($bledy !== []) { ?>
    <div class="blad">
        <p>Popraw formularz:</p>
        <ul>
            <?php foreach ($bledy as $blad) { ?>
                <li><?= $blad; ?></li>
            <?php } ?>
        </ul>
    </div>
<?php } ?>

<?php if ($bladZapisu !== null) { ?>
    <p class="blad">Nie udało się zapisać avatara: <?= $bladZapisu; ?></p>
<?php } ?>

<form method="post" action="profil.php" enctype="multipart/form-data">
    <label for="avatar">Avatar (JPG, PNG lub WEBP, najwyżej 1 MB)</label>
    <input type="file" id="avatar" name="avatar" accept=".jpg,.jpeg,.png,.webp" required>

    <button type="submit">Zapisz avatar</button>
</form>

<?php require_once __DIR__ . '/includes/footer.php'; ?>

Czym ten formularz różni się od formularza zadania?

  • ma enctype="multipart/form-data",
  • pole pliku nie ma value, więc po błędzie formularz nie zachowuje wybranego pliku. To nie przeoczenie: przeglądarka nie pozwala stronie wskazać pliku z dysku użytkownika. Inaczej każda strona mogłaby „wybrać” za Ciebie dowolny plik i go wysłać. Po błędzie gracz wybiera plik jeszcze raz.

Link do profilu. W index.php, bezpośrednio pod akapitem „Zalogowano jako …” (nad formularzem „Wyloguj”), dopisz:

<p><a href="profil.php">Twój profil — zmień avatar</a></p>

Sprawdzenie: z listy zadań wejdź w profil. Widzisz formularz. Wysłanie niczego jeszcze nie robi. Wyloguj się i wpisz w pasku adresu profil.php: przekierowanie na logowanie.

6b. SAMODZIELNIE: odbiór i walidacja pliku

Wewnątrz warunku POST:

  1. odczytaj z $_FILES tablicę pola avatar do zmiennej $plik. Pamiętaj o sytuacji z sekcji 3.6: przy przekroczeniu post_max_size ten klucz nie istnieje,
  2. jeśli $plik nie istnieje, dopisz do $bledy komunikat Plik jest za duży albo nie został wysłany.,
  3. w przeciwnym razie $bledy to wynik walidujAvatar().

Sprawdzenie: wyślij dokument.pdf. Oczekiwane: ramka „Popraw formularz:” z komunikatem o formacie.

HINT

Odczyt z tablicy, gdy klucza może nie być, robisz tak samo jak przy $_POST, tylko wartością zastępczą nie jest pusty tekst. Czego użyjesz, skoro w $plik ma być tablica albo „nic”?

ROZWIĄZANIE
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $plik = $_FILES['avatar'] ?? null;

    if ($plik === null) {
        $bledy[] = 'Plik jest za duży albo nie został wysłany.';
    } else {
        $bledy = walidujAvatar($plik);
    }
}

6c. SAMODZIELNIE: zapis pliku i bazy

Wciąż wewnątrz warunku POST, pod kodem z 6b, gdy $bledy jest puste:

  1. utwórz nazwę pliku w zmiennej $nazwaPlikuAvatara według wzoru avatar_<id gracza>_<aktualny czas>.<rozszerzenie>, np. avatar_1_1790000000.png. Id gracza jest w sesji, aktualny czas w sekundach zwraca funkcja znana z ciasteczek (Lekcja 14), rozszerzenie zwraca Twoja funkcja z kroku 4,
  2. w zmiennej $sciezkaAvatara zbuduj pełną ścieżkę na dysku: katalog tego pliku + / + KATALOG_AVATAROW + / + nazwa,
  3. przenieś plik tymczasowy pod tę ścieżkę. Jeśli się nie uda, ustaw $bladZapisu na Nie udało się zapisać pliku na serwerze.,
  4. jeśli się uda: w try połącz się z bazą, zapisz nazwę pliku funkcją z kroku 5, ustaw komunikat Avatar został zmieniony. i przekieruj na index.php. W catch wpisz komunikat wyjątku do $bladZapisu, jak w dodaj-zadanie.php.

Dlaczego taka nazwa pliku?

  • id gracza — dwaj gracze nigdy nie dostaną tej samej nazwy,
  • czas — każdy nowy avatar tego samego gracza ma inną nazwę. Przeglądarka pamięta obrazki (pamięć podręczna). Gdyby nowy avatar Kamy nazywał się jak stary, mogłaby dalej widzieć stary,
  • rozszerzenie sprawdzone przez walidację — plik na pewno kończy się na .jpg, .jpeg, .png albo .webp,
  • nic z nazwy od użytkownika poza rozszerzeniem — żadnych spacji, polskich znaków, .php w środku ani cudzych nazw.
HINT

Kolejność ma znaczenie. Najpierw plik, potem baza. Odwrotnie baza wskazywałaby plik, który może się nie zapisać. Warunek „gdy $bledy jest puste” już znasz z zaloguj.php. Wynik move_uploaded_file() sprawdzasz w if, tak jak wynik każdej funkcji zwracającej bool.

POMOC
    if ($bledy === []) {
        $nazwaPlikuAvatara = 'avatar_' . ... . '_' . ... . '.' . ...;
        $sciezkaAvatara = __DIR__ . '/' . KATALOG_AVATAROW . '/' . $nazwaPlikuAvatara;

        if (!move_uploaded_file(..., ...)) {
            $bladZapisu = 'Nie udało się zapisać pliku na serwerze.';
        } else {
            try {
                // połączenie, zapis w bazie, komunikat, przekierowanie
            } catch (Exception $wyjatek) {
                $bladZapisu = $wyjatek->getMessage();
            }
        }
    }
ROZWIĄZANIE
    if ($bledy === []) {
        $nazwaPlikuAvatara = 'avatar_' . $_SESSION['uzytkownik_id'] . '_' . time() . '.' . rozszerzenieAvatara($plik['name']);
        $sciezkaAvatara = __DIR__ . '/' . KATALOG_AVATAROW . '/' . $nazwaPlikuAvatara;

        if (!move_uploaded_file($plik['tmp_name'], $sciezkaAvatara)) {
            $bladZapisu = 'Nie udało się zapisać pliku na serwerze.';
        } else {
            try {
                $polaczenie = polaczZBaza();
                zapiszAvatarUzytkownika($polaczenie, $_SESSION['uzytkownik_id'], $nazwaPlikuAvatara);

                ustawKomunikat('Avatar został zmieniony.');
                header('Location: index.php');
                exit;
            } catch (Exception $wyjatek) {
                $bladZapisu = $wyjatek->getMessage();
            }
        }
    }

Nieudane move_uploaded_file() nie jest wyjątkiem, tylko wynikiem false. Sprawdzamy go więc warunkiem i nie rzucamy wyjątku po to, żeby go zaraz złapać w tym samym miejscu.

Przy awarii bazy plik zostaje w uploads/avatary/, ale baza go nie wskazuje. Powstaje „sierota” na dysku. Przy jednym graczu i rzadkich awariach to akceptowalne. Zobaczysz to w Teście 9.

6d. Sprawdzenie

Zaloguj się jako Kama, wejdź w profil, wyślij kot.png. Oczekiwane: powrót na listę zadań, komunikat „Avatar został zmieniony.”, w uploads/avatary/ plik avatar_1_<liczba>.png, w phpMyAdmin Kama ma w avatar tę samą nazwę. Samego avatara na stronie jeszcze nie widać. To następne zadanie.

Krok 7 — diagnostyka.php i stopka

7a. W diagnostyka.php dopisz do $wymaganePliki dwie ścieżki: 'profil.php' i 'includes/funkcje_avatara.php' (razem 21).

7b. Pod sekcją <h2>Baza danych</h2> dopisz sekcję <h2>Avatary</h2>. W stylu pozostałych sekcji wypisz jeden z trzech wyników:

  • [BRAK] Katalog uploads/avatary nie istnieje — utwórz go w katalogu projektu., gdy is_dir() zwraca false,
  • [BRAK] Do katalogu uploads/avatary nie można zapisywać plików., gdy katalog istnieje, ale is_writable() zwraca false,
  • [OK] Katalog uploads/avatary istnieje i można do niego zapisywać. w pozostałych przypadkach.

diagnostyka.php celowo nie dołącza config.php (Lekcja 10), więc ścieżkę wpisz wprost, tak jak w $wymaganePliki, doklejając ją do __DIR__ . '/'.

<h2>Avatary</h2>
<?php if (!is_dir(__DIR__ . '/uploads/avatary')) { ?>
    <p>[BRAK] Katalog uploads/avatary nie istnieje — utwórz go w katalogu projektu.</p>
<?php } elseif (!is_writable(__DIR__ . '/uploads/avatary')) { ?>
    <p>[BRAK] Do katalogu uploads/avatary nie można zapisywać plików.</p>
<?php } else { ?>
    <p>[OK] Katalog uploads/avatary istnieje i można do niego zapisywać.</p>
<?php } ?>

Na Windows w XAMPP katalog praktycznie zawsze będzie zapisywalny. Na serwerze z Linuksem brak uprawnień do zapisu to najczęstsza przyczyna, dla której upload „działa u mnie, a nie na serwerze”.

7c. W includes/footer.php zmień wersję na TaskQuest 2.1 (Lekcja 20 jest pominięta, więc numer 2.0 też).

6. Kolejne zadanie — avatar na stronie głównej

Avatar jest zapisany, ale nikt go nie widzi. Dodaj go do index.php.

Wymagania:

  1. Obrazek stoi bezpośrednio nad zdaniem powitalnym „Witaj, …!” (tym, w którym jest $uzytkownik->pobierzImie()).
  2. Gdy gracz ma avatar: <img> z klasą avatar, adresem KATALOG_AVATAROW + / + nazwa pliku z obiektu i tekstem alternatywnym Avatar gracza.
  3. Gdy gracz nie ma avatara: <span> z klasami avatar i avatar-litera, a w środku pierwsza litera imienia. Imię może zaczynać się polską literą („Łucja”), więc pamiętaj, które funkcje tekstowe znają polskie znaki (Lekcja 8).
  4. Użyj konstrukcji <?php if (...) { ?> ... <?php } else { ?> ... <?php } ?>, tak jak w pozostałej części prezentacji.

Style dopisz w includes/header.php, w <style>, pod regułą form.akcja:

.avatar { display: inline-block; width: 64px; height: 64px; border-radius: 50%; object-fit: cover; vertical-align: middle; }
.avatar-litera { background: #5b6ee1; color: #fff; font-size: 32px; font-weight: bold; line-height: 64px; text-align: center; }

border-radius: 50% robi z kwadratu koło. object-fit: cover przycina zdjęcie do kwadratu, zamiast je rozciągać.

HINT

Gracz „nie ma avatara”, gdy getter zwraca null. Pierwszą literę wytniesz funkcją, której używa skrocOpis().

POMOC
<?php if ($uzytkownik->pobierzAvatar() !== null) { ?>
    <img class="avatar" src="..." alt="Avatar gracza">
<?php } else { ?>
    <span class="avatar avatar-litera"><?= ...; ?></span>
<?php } ?>
ROZWIĄZANIE
<?php if ($uzytkownik->pobierzAvatar() !== null) { ?>
    <img class="avatar" src="<?= KATALOG_AVATAROW . '/' . $uzytkownik->pobierzAvatar(); ?>" alt="Avatar gracza">
<?php } else { ?>
    <span class="avatar avatar-litera"><?= mb_substr($uzytkownik->pobierzImie(), 0, 1); ?></span>
<?php } ?>

substr($imie, 0, 1) dla „Łucja” wyciąga pierwszy bajt, a „Ł” w UTF-8 zajmuje dwa bajty. Na stronie pojawiłby się znak „�” (sprawdź w Teście 1). mb_substr() liczy znaki.

index.php nie potrzebuje nowych require_once: KATALOG_AVATAROW jest w config.php, który strona już dołącza.

Sprawdzenie: Kama (z avatarem z kroku 6d) widzi swój obrazek w kółku. Ola widzi fioletowe kółko z literą „O”.

7. Testy

Przed testami uruchom ponownie sql/taskquest.sql i opróżnij uploads/avatary/ (sam katalog zostaje).

Test 1 — regresja i litera

Zaloguj się jako Kama, potem jako Ola. Oczekiwane: kółka z literami „K” i „O”, reszta strony jak po Lekcji 19 (zadania, „Zrobione!”, „Usuń”, ranking).

W phpMyAdmin zmień imię Oli na Łucja i odśwież. Oczekiwane: „Ł”. Tymczasowo zamień w index.php mb_substr na substr i odśwież: zamiast litery pojawia się �. Przywróć mb_substr i imię Ola.

Test 2 — pierwszy avatar

Jako Kama wyślij kot.png. Oczekiwane: komunikat „Avatar został zmieniony.”, obrazek w kółku, w uploads/avatary/ jeden plik avatar_1_<liczba>.png, w bazie ta sama nazwa. Kliknij prawym przyciskiem na avatar → „Otwórz grafikę w nowej karcie”: adres kończy się na uploads/avatary/avatar_1_....png.

Test 3 — zmiana avatara

Jako Kama wyślij inny poprawny obrazek (np. kot.png zapisany jako .jpg albo inny zrzut ekranu). Oczekiwane: nowy obrazek od razu, bez odświeżania z czyszczeniem pamięci. W uploads/avatary/ są teraz dwa pliki Kamy, a baza wskazuje nowszy (ograniczenie z początku lekcji; zadanie końcowe A).

Test 4 — walidacja przez formularz

Dla każdego pliku przewidź komunikat, potem sprawdź. Po każdym błędzie avatar i baza bez zmian.

PlikOczekiwany komunikat
duzy.jpgPlik może mieć najwyżej 1 MB.
dokument.pdfDozwolone formaty: JPG, PNG, WEBP.
notatka.pngWybrany plik nie jest obrazkiem.
pusty.pngWybrany plik jest pusty.
kot.png przemianowany na KOT.PNGsukces

Przy dokument.pdf zmień w oknie wyboru pliku filtr na „Wszystkie pliki” (accept tylko podpowiada).

Po błędzie pole pliku jest puste. To oczekiwane zachowanie (krok 6a).

Test 5 — formularz bez pliku

W narzędziach deweloperskich (F12 → Elements/Inspektor) usuń z pola pliku atrybut required i kliknij „Zapisz avatar” bez wybierania pliku. Oczekiwane: Wybierz plik z obrazkiem. To dowód, że PHP sprawdza zawsze, niezależnie od przeglądarki.

Test 6 — każdy gracz ma swój avatar

Zaloguj się jako Ola i wyślij obrazek. Oczekiwane: plik avatar_2_<liczba>..., Ola widzi swój avatar. Zaloguj się jako Kama: nadal widzi swój.

Test 7 — wejście bez formularza

Wpisz w pasku adresu profil.php (GET), będąc zalogowaną/zalogowanym. Oczekiwane: sam formularz, bez komunikatów. Wyloguj się i wejdź na profil.php: przekierowanie na zaloguj.php.

Test 8 — diagnostyka

Otwórz diagnostyka.php. Oczekiwane: 21 plików [OK], sekcja „Avatary” z [OK]. Zmień nazwę katalogu uploads/avatary na avatary_x i odśwież: [BRAK] Katalog ... nie istnieje. Przywróć nazwę.

Test 9 — baza nie działa

Zatrzymaj MySQL w XAMPP. Wyślij poprawny obrazek. Oczekiwane: ramka „Nie udało się zapisać avatara: Nie udało się połączyć z bazą danych.” Zajrzyj do uploads/avatary/: plik jest, choć baza go nie wskazuje. Uruchom MySQL. Ten plik możesz usunąć ręcznie.

8. Obowiązkowe eksperymenty

Po każdym eksperymencie przywróć kod.

Eksperyment 1: formularz bez enctype

W brudnopis.php usuń z formularza enctype="multipart/form-data". Pod print_r($_FILES); dopisz print_r($_POST);. Wyślij kot.png.

Oczekiwane: $_FILES jest puste, a w $_POST jest [avatar] => kot.png. Przeglądarka wysłała zwykły formularz tekstowy, a za wartość pola pliku wzięła jego nazwę. Zawartość pliku nie opuściła komputera.

Pytanie: co zrobiłoby profil.php, gdyby ktoś zapomniał enctype? (Sprawdź: usuń atrybut w profil.php i wyślij obrazek.)

Eksperyment 2: bez sprawdzania zawartości

W walidujAvatar() zamień warunek z getimagesize() na komentarz (całą gałąź elseif razem z jej zawartością). Jako Kama wyślij notatka.png.

Oczekiwane: „Avatar został zmieniony.”, a w kółku ikonka zepsutego obrazka. Otwórz ten „avatar” w nowej karcie: przeglądarka próbuje pokazać tekst jako obrazek.

Teraz wyobraź sobie, że w notatka.png nie ma zwykłego tekstu, tylko kod PHP. Ponieważ plik kończy się na .png, Apache pokaże go jako obrazek i nie wykona go. Ochronę daje więc przede wszystkim rozszerzenie z listy dozwolonych, a getimagesize() pilnuje, żeby avatar był obrazkiem, a nie czymkolwiek. Pytanie: która linia kodu musiałaby zawieść, żeby na serwer trafił plik z końcówką .php? (Wskazówki: krok 4 i 6c.)

Przywróć warunek. Uruchom ponownie sql/taskquest.sql i usuń plik z uploads/avatary/.

Eksperyment 3: nazwa od użytkownika

W profil.php tymczasowo zamień budowanie nazwy na:

$nazwaPlikuAvatara = $plik['name'];

Zaloguj się jako Kama i wyślij obrazek o nazwie avatar.png. Zaloguj się jako Ola i wyślij inny obrazek, też nazwany avatar.png. Zaloguj się z powrotem jako Kama.

Oczekiwane: Kama ma avatar Oli. W katalogu jest jeden plik, drugi go nadpisał, a w bazie oboje wskazują tę samą nazwę. Ktoś mógłby w ten sposób podmieniać avatary innym graczom, znając tylko nazwę ich pliku. Pomyśl też, co by się stało przy nazwie pliku atak.php, gdyby lista rozszerzeń miała błąd.

Przywróć kod. Uruchom ponownie sql/taskquest.sql i opróżnij uploads/avatary/.

Eksperyment 4: brak katalogu

Zmień nazwę uploads/avatary na avatary_x. Wyślij poprawny obrazek.

Oczekiwane: nad stroną ostrzeżenie PHP Warning: move_uploaded_file(...): Failed to open stream: No such file or directory (oraz drugie, Unable to move ...), a pod nim nasza ramka „Nie udało się zapisać avatara: Nie udało się zapisać pliku na serwerze.”. Funkcja zwróciła false i nasz kod to obsłużył. Ostrzeżenia wypisał sam PHP. Zwróć uwagę, że zawierają pełne ścieżki na dysku serwera. Użytkownik nie powinien ich widzieć. Jak wyłączyć ich wyświetlanie, poznasz w Lekcji 23.

Przywróć nazwę katalogu.

Eksperyment 5 (dla chętnych albo pokaz nauczyciela): limity serwera

  1. W panelu XAMPP przy Apache kliknij „Config” → „PHP (php.ini)”. Znajdź upload_max_filesize i post_max_size, zapisz obecne wartości i zmień je na upload_max_filesize=500K i post_max_size=2M. Zapisz plik i zrestartuj Apache (Stop → Start).
  2. Wyślij obrazek o rozmiarze między 500 KB a 1 MB. Oczekiwane: Plik może mieć najwyżej 1 MB., choć plik ma mniej niż 1 MB. Zadziałał limit serwera (UPLOAD_ERR_INI_SIZE), a nasz komunikat wprowadza w błąd. Wniosek: własny limit aplikacji musi być niższy niż limity serwera.
  3. Wyślij duzy.jpg (powyżej 2 MB). Oczekiwane: Plik jest za duży albo nie został wysłany., bo przekroczony post_max_size sprawił, że $_FILES jest puste. Nad stroną może się też pojawić ostrzeżenie PHP POST Content-Length of ... bytes exceeds the limit of ... bytes. PHP zgłasza je, zanim uruchomi nasz skrypt. Tymczasowo zamień w profil.php $_FILES['avatar'] ?? null na $_FILES['avatar'] i wyślij plik jeszcze raz: Warning: Undefined array key "avatar" i dalsze błędy.
  4. Przywróć kod i oryginalne wartości w php.ini, zrestartuj Apache.

9. ROZWIĄZANIE

Poniżej wszystkie pliki, które zmieniły się w Lekcji 21. Pliki niewymienione są identyczne jak po Lekcji 19 (wersja w 19_TaskQuest.md, sekcja 9, a tam, gdzie L19 odsyła dalej — w 18_TaskQuest.md).

ROZWIĄZANIE

Nowy katalog

uploads/avatary/        pusty katalog; tworzony ręcznie, nie przez aplikację

sql/taskquest.sql — zmiana w CREATE TABLE uzytkownicy

CREATE TABLE uzytkownicy (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    login VARCHAR(20) NOT NULL UNIQUE,
    imie VARCHAR(30) NOT NULL,
    haslo_hash VARCHAR(255) NULL,
    xp INT UNSIGNED NOT NULL DEFAULT 0,
    streak INT UNSIGNED NOT NULL DEFAULT 0,
    data_ostatniej_aktywnosci DATE NULL,       -- NULL = brak aktywności
    avatar VARCHAR(100) NULL                   -- (L21) nazwa pliku w uploads/avatary; NULL = brak avatara
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Reszta skryptu bez zmian (jeśli INSERT INTO uzytkownicy nie wymienia kolumn — patrz krok 1).

config/config.php — dopisane na końcu

// (L21) avatary graczy
define('KATALOG_AVATAROW', 'uploads/avatary');          // względem katalogu projektu
define('MAKS_ROZMIAR_AVATARA', 1024 * 1024);           // 1 MB w bajtach
define('DOZWOLONE_ROZSZERZENIA_AVATARA', ['jpg', 'jpeg', 'png', 'webp']);

includes/funkcje_avatara.php (nowy plik)

<?php

// Funkcje TaskQuest do obsługi avatarów (Lekcja 21).
// Wymagają wcześniejszego dołączenia config/config.php (stałe ..._AVATARA).

function rozszerzenieAvatara(string $nazwaPliku): string
{
    return strtolower(pathinfo($nazwaPliku, PATHINFO_EXTENSION));
}

function walidujAvatar(array $plik): array
{
    $bledy = [];

    if ($plik['error'] === UPLOAD_ERR_NO_FILE) {
        $bledy[] = 'Wybierz plik z obrazkiem.';
        return $bledy;
    }

    if ($plik['error'] === UPLOAD_ERR_INI_SIZE || $plik['error'] === UPLOAD_ERR_FORM_SIZE) {
        $bledy[] = 'Plik może mieć najwyżej 1 MB.';
        return $bledy;
    }

    if ($plik['error'] !== UPLOAD_ERR_OK) {
        $bledy[] = 'Nie udało się przesłać pliku. Spróbuj ponownie.';
        return $bledy;
    }

    if ($plik['size'] === 0) {
        $bledy[] = 'Wybrany plik jest pusty.';
        return $bledy;
    }

    if ($plik['size'] > MAKS_ROZMIAR_AVATARA) {
        $bledy[] = 'Plik może mieć najwyżej 1 MB.';
    }

    if (!in_array(rozszerzenieAvatara($plik['name']), DOZWOLONE_ROZSZERZENIA_AVATARA, true)) {
        $bledy[] = 'Dozwolone formaty: JPG, PNG, WEBP.';
    } elseif (getimagesize($plik['tmp_name']) === false) {
        $bledy[] = 'Wybrany plik nie jest obrazkiem.';
    }

    return $bledy;
}

includes/funkcje_bazy.php — zmiany

  1. Komentarz na górze: „(Lekcje 18–21)”.
  2. wczytajUzytkownika() — SELECT id, login, imie, xp, streak, data_ostatniej_aktywnosci, avatar FROM uzytkownicy WHERE id = ? oraz ostatni argument avatar: $wiersz['avatar'], w new Uzytkownik(...).
  3. Na końcu pliku:
function zapiszAvatarUzytkownika(mysqli $polaczenie, int $uzytkownikId, string $nazwaPlikuAvatara): void
{
    $zapytanie = $polaczenie->prepare('UPDATE uzytkownicy SET avatar = ? WHERE id = ?');
    $zapytanie->bind_param('si', $nazwaPlikuAvatara, $uzytkownikId);
    $zapytanie->execute();
}

classes/Uzytkownik.php — zmiany

  • nowa ostatnia właściwość private ?string $avatar; (pod dataOstatniejAktywnosci),
  • konstruktor: nowy ostatni parametr ?string $avatar, ostatnia linia ciała $this->avatar = $avatar;,
  • nowa metoda pod pobierzStreak():
    public function pobierzAvatar(): ?string
    {
        return $this->avatar;
    }

Pozostałe metody bez zmian.

profil.php (nowy plik)

<?php

session_start();

require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/includes/funkcje_avatara.php';
require_once __DIR__ . '/includes/funkcje_sesji.php';
require_once __DIR__ . '/includes/funkcje_bazy.php';

wymagajLogowania();

$bledy = [];
$bladZapisu = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $plik = $_FILES['avatar'] ?? null;

    if ($plik === null) {
        $bledy[] = 'Plik jest za duży albo nie został wysłany.';
    } else {
        $bledy = walidujAvatar($plik);
    }

    if ($bledy === []) {
        $nazwaPlikuAvatara = 'avatar_' . $_SESSION['uzytkownik_id'] . '_' . time() . '.' . rozszerzenieAvatara($plik['name']);
        $sciezkaAvatara = __DIR__ . '/' . KATALOG_AVATAROW . '/' . $nazwaPlikuAvatara;

        if (!move_uploaded_file($plik['tmp_name'], $sciezkaAvatara)) {
            $bladZapisu = 'Nie udało się zapisać pliku na serwerze.';
        } else {
            try {
                $polaczenie = polaczZBaza();
                zapiszAvatarUzytkownika($polaczenie, $_SESSION['uzytkownik_id'], $nazwaPlikuAvatara);

                ustawKomunikat('Avatar został zmieniony.');
                header('Location: index.php');
                exit;
            } catch (Exception $wyjatek) {
                $bladZapisu = $wyjatek->getMessage();
            }
        }
    }
}

$nazwaStrony = 'Profil — TaskQuest';
require_once __DIR__ . '/includes/header.php';
?>

<h2>Twój profil</h2>
<p><a href="index.php">← Wróć do listy zadań</a></p>

<?php if ($bledy !== []) { ?>
    <div class="blad">
        <p>Popraw formularz:</p>
        <ul>
            <?php foreach ($bledy as $blad) { ?>
                <li><?= $blad; ?></li>
            <?php } ?>
        </ul>
    </div>
<?php } ?>

<?php if ($bladZapisu !== null) { ?>
    <p class="blad">Nie udało się zapisać avatara: <?= $bladZapisu; ?></p>
<?php } ?>

<form method="post" action="profil.php" enctype="multipart/form-data">
    <label for="avatar">Avatar (JPG, PNG lub WEBP, najwyżej 1 MB)</label>
    <input type="file" id="avatar" name="avatar" accept=".jpg,.jpeg,.png,.webp" required>

    <button type="submit">Zapisz avatar</button>
</form>

<?php require_once __DIR__ . '/includes/footer.php'; ?>

index.php — zmiany

  1. Bezpośrednio nad zdaniem powitalnym „Witaj, …!”:
<?php if ($uzytkownik->pobierzAvatar() !== null) { ?>
    <img class="avatar" src="<?= KATALOG_AVATAROW . '/' . $uzytkownik->pobierzAvatar(); ?>" alt="Avatar gracza">
<?php } else { ?>
    <span class="avatar avatar-litera"><?= mb_substr($uzytkownik->pobierzImie(), 0, 1); ?></span>
<?php } ?>
  1. Pod akapitem „Zalogowano jako …”, nad formularzem „Wyloguj”:
<p><a href="profil.php">Twój profil — zmień avatar</a></p>

Reszta pliku (dane z bazy, obsługa awarii, liczniki, widok listy, przyciski przy zadaniach, pasek XP, ranking) bez zmian. require_once bez zmian.

includes/header.php

W <style>, pod form.akcja { display: inline; }:

.avatar { display: inline-block; width: 64px; height: 64px; border-radius: 50%; object-fit: cover; vertical-align: middle; }
.avatar-litera { background: #5b6ee1; color: #fff; font-size: 32px; font-weight: bold; line-height: 64px; text-align: center; }

diagnostyka.php

W $wymaganePliki dopisane 'profil.php' i 'includes/funkcje_avatara.php' (razem 21 ścieżek). Pod sekcją „Baza danych” nowa sekcja:

<h2>Avatary</h2>
<?php if (!is_dir(__DIR__ . '/uploads/avatary')) { ?>
    <p>[BRAK] Katalog uploads/avatary nie istnieje — utwórz go w katalogu projektu.</p>
<?php } elseif (!is_writable(__DIR__ . '/uploads/avatary')) { ?>
    <p>[BRAK] Do katalogu uploads/avatary nie można zapisywać plików.</p>
<?php } else { ?>
    <p>[OK] Katalog uploads/avatary istnieje i można do niego zapisywać.</p>
<?php } ?>

Reszta bez zmian (nadal bez sesji, bez config.php, bez połączenia z bazą).

includes/footer.php

        <p>TaskQuest 2.1</p>
    </div>
</body>
</html>

10. Co zmieniliśmy w TaskQuest?

Zostaje

wszystko z Lekcji 19: logowanie (login bez hasła), sesja z uzytkownik_id, zadania w bazie,
dodawanie, „Zrobione!” z XP i streakiem, usuwanie, ranking, filtr widoku z cookie, komunikaty jednorazowe
schemat strony z formularzem (L13), walidacja zwracająca tablicę błędów, POST → przekierowanie → GET
funkcje bazy z L18–L19, klasy Zadanie i Uzytkownik (poza nowym polem), wszystkie funkcje z L1–L15

Dochodzi

baza: uzytkownicy.avatar VARCHAR(100) NULL
config.php: KATALOG_AVATAROW, MAKS_ROZMIAR_AVATARA, DOZWOLONE_ROZSZERZENIA_AVATARA
includes/funkcje_avatara.php:
    rozszerzenieAvatara(string $nazwaPliku): string
    walidujAvatar(array $plik): array
includes/funkcje_bazy.php:
    zapiszAvatarUzytkownika(mysqli, int $uzytkownikId, string $nazwaPlikuAvatara): void
Uzytkownik: ?string $avatar, pobierzAvatar(): ?string
profil.php — formularz avatara (multipart/form-data)
uploads/avatary/ — katalog na pliki
index.php: avatar albo litera przy powitaniu, link do profilu
header.php: .avatar, .avatar-litera
diagnostyka.php: 21 plików, sekcja „Avatary”

Nowe narzędzia: enctype="multipart/form-data", <input type="file"> z accept, tablica $_FILES (name, type, tmp_name, error, size), stałe UPLOAD_ERR_..., move_uploaded_file(), getimagesize(), pathinfo(..., PATHINFO_EXTENSION), strtolower(), in_array(..., true), stała z tablicą, is_dir(), is_writable(), ini_get() (brudnopis), limity upload_max_filesize i post_max_size.

Zmieniamy

  • wczytajUzytkownika(): czyta kolumnę avatar,
  • konstruktor Uzytkownik: nowy ostatni parametr,
  • stopka: 2.1.

Usuwamy

Nic.

Co nowego potrafi TaskQuest po tej lekcji?

Po Lekcji 21 TaskQuest przyjmuje od gracza plik: każdy może wgrać własny avatar, który pojawia się obok powitania. Aplikacja odrzuca pliki za duże, puste, w złym formacie i takie, które tylko udają obrazek, a zapisuje je pod własną nazwą, więc gracze nie nadpisują sobie plików. Gracz bez avatara widzi kółko z pierwszą literą imienia.

11. Zadania końcowe — rozbuduj TaskQuest samodzielnie

Nie są częścią wersji referencyjnej.

A. Sprzątanie po starym avatarze. Po udanej zmianie w bazie usuń poprzedni plik gracza funkcją unlink($sciezka). Skąd weźmiesz nazwę starego pliku? (Musisz ją odczytać przed zapisem nowej — którą funkcją?) Co, jeśli gracz nie miał avatara (null)? Dlaczego stary plik usuwamy dopiero po udanym zapisie w bazie, a nie przed? Po zmianie Test 3 ma zostawiać w katalogu jeden plik Kamy.

B. Przycisk „Usuń avatar”. Na profil.php drugi formularz (POST, bez pliku) z przyciskiem „Usuń avatar”. Nowa strona akcji usun-avatar.php w stylu usun-zadanie.php: ustawia w bazie avatar = NULL (nowa funkcja usunAvatarUzytkownika(); jak przekazać NULL — czy potrzebujesz bind_param() dla tego NULL?), usuwa plik z dysku, ustawia komunikat i przekierowuje. Gracz znów widzi kółko z literą.

C. Avatary w rankingu. W „Najlepsi gracze” obok loginu mały avatar (np. klasa .avatar-maly 24×24 px) albo kółko z pierwszą literą loginu. Co musisz zmienić w zapytaniu pobierzRanking()? Czy powtórzysz w pętli całą konstrukcję if/else z sekcji 6, czy zamkniesz ją w funkcji zwracającej fragment HTML? Uzasadnij wybór.

Sprzątanie

  1. Usuń brudnopis.php, brudnopis2.php, katalog test_upload/ oraz kopie kot.png i notatka.png z katalogu projektu.
  2. Sprawdź, że przywrócone są: enctype w profil.php (Eksperyment 1), gałąź z getimagesize() (Eksperyment 2), nazwa pliku avatar_<id>_<czas> (Eksperyment 3), nazwa katalogu uploads/avatary (Eksperyment 4, Test 8), ?? null przy $_FILES i wartości w php.ini (Eksperyment 5), mb_substr w index.php (Test 1).
  3. Uruchom ponownie sql/taskquest.sql i opróżnij uploads/avatary/ (katalog zostaje).
  4. Zaloguj się jako Kama, wgraj avatar, sprawdź, że się wyświetla. diagnostyka.php — wszystko [OK].

Do samodzielnego sprawdzenia

[ ] Nadal rozwijam ten sam projekt TaskQuest (nie zaczynam nowego).
[ ] Potrafię zbudować formularz wysyłający plik i wiem, po co jest enctype="multipart/form-data".
[ ] Znam pięć kluczy tablicy $_FILES['pole'] i wiem, którym z nich można ufać.
[ ] Wiem, co się dzieje z plikiem tymczasowym, gdy skrypt go nie przeniesie.
[ ] Potrafię rozpoznać brak pliku, przekroczony limit i przerwane przesyłanie po kodzie UPLOAD_ERR_...
[ ] Wiem, dlaczego $_FILES['avatar'] czytam przez ?? null.
[ ] Potrafię sprawdzić rozszerzenie pliku z listą dozwolonych i wiem, po co strtolower().
[ ] Wiem, co sprawdza getimagesize() i czego nie sprawdza samo rozszerzenie.
[ ] Potrafię wyjaśnić, dlaczego plik zapisuję pod własną nazwą, a nie pod $_FILES['avatar']['name'].
[ ] Wiem, dlaczego w bazie zapisuję nazwę pliku, a nie cały plik ani pełną ścieżkę.
[ ] Wiem, dlaczego pole pliku nie zachowuje wybranego pliku po błędzie.
[ ] Usunęłam/usunąłem brudnopisy i pliki testowe.

Pytania kontrolne

1. Formularz ma method="post", pole <input type="file" name="zdjecie">, ale nie ma enctype. Gracz wybiera wakacje.jpg. Co jest w $_FILES, a co w $_POST?

2. Przewidź wynik walidujAvatar() (lista komunikatów) dla:
a) name = Selfie.JPEG, prawdziwe zdjęcie, error = 0, size = 800 000,
b) name = mem.gif, prawdziwy obrazek, error = 0, size = 3 000 000,
c) name = skrypt.php.jpg, zawartość to kod PHP, error = 0, size = 300,
d) error = UPLOAD_ERR_NO_FILE.

3. Kolega sprawdza format tak: if ($plik['type'] !== 'image/png' && $plik['type'] !== 'image/jpeg') { ... }. Dlaczego to nie chroni aplikacji? Co sprawdza TaskQuest zamiast tego?

4. Koleżanka pominęła move_uploaded_file(): po walidacji od razu zapisuje w bazie nazwę pliku i przekierowuje. Co zobaczy gracz na stronie głównej i dlaczego?

5. Dlaczego nowy avatar Kamy dostaje nazwę avatar_1_<czas>.png, a nie po prostu avatar_1.png? Podaj dwa powody.

6. $_FILES['avatar'] nie istnieje, choć gracz wysłał formularz metodą POST. Jaka jest najbardziej prawdopodobna przyczyna? Co pokaże TaskQuest?

7. Serwer ma upload_max_filesize = 512K, a TaskQuest dopuszcza 1 MB. Gracz wysyła obrazek 700 KB. Który warunek w walidujAvatar() zadziała i dlaczego komunikat wprowadzi gracza w błąd? Jak to naprawić bez zmiany kodu PHP?

8. Znajdź trzy błędy:

$plik = $_FILES['avatar'];
if ($plik['error'] == 0 && $plik['size'] < 1000000) {
move_uploaded_file($plik['tmp_name'], 'uploads/avatary/' . $plik['name']);
}
9. Dlaczego w kolumnie avatar zapisujemy avatar_1_1790000000.png, a nie uploads/avatary/avatar_1_1790000000.png? Co trzeba by zrobić z bazą w każdym z tych wariantów, gdyby katalog zmienił nazwę na media/avatary?

10. Po awarii bazy w Teście 9 w uploads/avatary/ został plik, którego baza nie wskazuje. Czy zamiana kolejności (najpierw baza, potem plik) rozwiązałaby problem? Co wtedy mogłoby pójść źle?


PODSUMOWANIE LEKCJI

W skrócie

Czym są superglobale

  • To 9 wbudowanych tablic PHP dostępnych w każdym zakresie: w skrypcie, w funkcji, w metodzie klasy — bez słowa global i bez przekazywania w parametrze.
  • Wszystkie są zwykłymi tablicami: czytamy je jak każdą inną ($_POST['login']), więc brakujący klucz to ostrzeżenie. Dlatego zawsze isset() albo ?? wartość domyślna.

Komu ufamy, a komu nie

Tablica Kto wpisuje dane Ufamy?
$_GET, $_POST, $_COOKIE, $_REQUEST użytkownik (przeglądarka) nie
$_FILES – name, type przeglądarka użytkownika nie
$_FILES – size, error, tmp_name PHP tak
$_SESSION nasz kod na serwerze tak
$_SERVER – HTTP_* (m.in. HTTP_USER_AGENT, HTTP_REFERER) klient nie
$_SERVER – reszta (m.in. REQUEST_METHOD, REMOTE_ADDR, DOCUMENT_ROOT) serwer tak

To jest sedno całej lekcji: superglobala nie dzielimy na „bezpieczną” i „niebezpieczną” jako całość, tylko pytamy o każdy klucz z osobna, kto go ustawił.

Rzeczy, które trzeba pamiętać, bo psują kod

  • session_start() musi być przed jakimkolwiek wyjściem (HTML, echo), tak samo setcookie() i header().
  • Ciasteczko ustawione przez setcookie() pojawi się w $_COOKIE dopiero przy następnym żądaniu — w tym samym skrypcie go jeszcze nie odczytasz.
  • $_REQUEST i $GLOBALS działają, ale w tym kursie ich nie używamy: $_REQUEST miesza trzy źródła danych (nie wiadomo, skąd przyszła wartość), a zamiast $GLOBALS przekazujemy zmienne parametrami funkcji.
  • GET nadaje się do wyszukiwania, filtrowania, sortowania, stronicowania i linków do zasobu. Nie nadaje się do haseł ani do zmiany danych w bazie — adres da się zapisać w zakładce, wysłać komuś i odświeżyć.

$_FILES i przesyłanie pliku

  • Formularz z plikiem wymaga dwóch rzeczy naraz: method="post" i enctype="multipart/form-data". Bez enctype w $_FILES nie ma nic, a w $_POST jest sama nazwa pliku.
  • Zanim ruszy nasz skrypt, PHP zapisuje plik w katalogu tymczasowym pod losową nazwą. Ten plik znika po zakończeniu skryptu — jeśli go nie przeniesiemy move_uploaded_file(), przepada.
  • move_uploaded_file() zamiast copy()/rename(), bo sprawdza, czy ścieżka wskazuje plik faktycznie przesłany w tym żądaniu.
  • Kolejność walidacji: najpierw error, potem rozmiar, potem rozszerzenie z listy dozwolonych, na końcu zawartość (getimagesize()). Gdy error jest różny od UPLOAD_ERR_OK, reszta kluczy nic nie znaczy.
  • Rozszerzenie i type mówią tylko, jak plik się nazywa. O tym, czym plik jest, mówi dopiero zajrzenie do zawartości.
  • Plik zapisujemy pod własną nazwą (avatar_<id>_<czas>.<rozszerzenie>), a w bazie trzymamy samą nazwę pliku — nie zawartość i nie pełną ścieżkę.
  • Przekroczony post_max_size to pułapka: żądanie jest metodą POST, ale $_POST i $_FILES są puste. Dlatego $_FILES['avatar'] ?? null.

Co trzeba umieć na INF.03?

  • odczytać parametr przesłany metodą GET i podstawić wartość domyślną, gdy go nie ma — w arkuszu: „pobiera wartość przesłaną metodą GET, a w przypadku braku – wartością 6”;
  • zbudować odnośnik, który przekazuje parametr GET do tej samej strony — <a href="index.php?id=3">Europa</a> generowane w pętli z rekordów z bazy;
  • rozpoznać polecenie „formularz wysyłający dane metodą bezpieczną” — chodzi o method="post", i umieć wskazać w action ten sam plik albo inny skrypt;
  • sprawdzić, czy formularz w ogóle został wysłany, zanim skrypt zacznie zapisywać do bazy — w arkuszu: „działa tylko w przypadku, gdy z formularza zostały przesłane dane”. Wystarczy isset($_POST['pole']) lub $_SERVER['REQUEST_METHOD'] === 'POST' (isset() jest w tabeli funkcji dołączonej do arkusza);
  • wstawić do bazy wartości pobrane z formularza i na końcu zamknąć połączenie z bazą — to osobny punkt wymagań w arkuszu;
  • użyć sesji i ciasteczek do zapamiętania stanu użytkownika między stronami (session_start(), $_SESSION, setcookie(), $_COOKIE).

Minimum, które powinno wejść w pamięć

Jeżeli masz zapamiętać tylko kilka rzeczy z tej lekcji, zapamiętaj te:

  1. Superglobale działają wszędzie, także w funkcji — bez global.
  2. $_GET i $_POST to dane od użytkownika. Czytaj je zawsze przez ?? '' lub isset() i rzutuj liczby: (int)($_GET['id'] ?? 0).
  3. GET = pobieranie i filtrowanie, POST = zmiana danych, logowanie, pliki.
  4. $_SESSION żyje na serwerze i jej ufamy. $_COOKIE żyje w przeglądarce i jej nie ufamy.
  5. session_start(), setcookie(), header() — zawsze przed jakimkolwiek wypisaniem treści.
  6. Formularz z plikiem: method="post" + enctype="multipart/form-data".
  7. $_FILES['pole'] ma 5 kluczy: name, type, tmp_name, error, size. Ufamy trzem ostatnim.
  8. Bez move_uploaded_file() plik znika po zakończeniu skryptu.
  9. Rozszerzenie i type można podrobić — zawartość obrazka sprawdza getimagesize().
  10. Plik zapisujemy pod własną, unikalną nazwą; w bazie trzymamy tylko tę nazwę.

Ściągawka – najważniejsze polecenia i składnia

Odczyt danych od użytkownika

$id     = (int)($_GET['id'] ?? 0);        // liczba z adresu, 0 gdy brak
$szukaj = trim($_GET['q'] ?? '');         // tekst z adresu
$login  = trim($_POST['login'] ?? '');    // tekst z formularza
$motyw  = $_COOKIE['motyw'] ?? 'jasny';   // ciasteczko z wartością domyślną

if ($_SERVER['REQUEST_METHOD'] === 'POST') { /* formularz został wysłany */ }
if (isset($_POST['login'])) { /* wersja egzaminacyjna: sprawdzenie pola */ }

echo htmlspecialchars($szukaj);           // zanim pokażesz dane użytkownika na stronie

Sesja

session_start();                          // przed jakimkolwiek wyjściem
$_SESSION['uzytkownik_id'] = 1;
$id = $_SESSION['uzytkownik_id'] ?? null;
unset($_SESSION['komunikat']);            // usunięcie jednego klucza
session_destroy();                        // koniec całej sesji (wylogowanie)

Ciasteczka

setcookie('motyw', 'ciemny', time() + 30 * 24 * 60 * 60);   // na 30 dni
setcookie('motyw', '', time() - 3600);                      // usunięcie
// odczyt dopiero przy NASTĘPNYM żądaniu: $_COOKIE['motyw']

Formularz z plikiem

<form method="post" action="profil.php" enctype="multipart/form-data">
    <input type="file" name="avatar" accept=".jpg,.jpeg,.png,.webp" required>
    <button type="submit">Zapisz avatar</button>
</form>

Odbiór i walidacja pliku

$plik = $_FILES['avatar'] ?? null;                 // null, gdy przekroczono post_max_size

$plik['error']    // 0 = OK; 1 = za duży (php.ini); 3 = urwane; 4 = nie wybrano pliku
$plik['size']     // rozmiar w bajtach (PHP)
$plik['tmp_name'] // plik tymczasowy — znika po skrypcie
$plik['name']     // nazwa od użytkownika — tylko do wyciągnięcia rozszerzenia
$plik['type']     // typ od przeglądarki — nie używamy

$rozszerzenie = strtolower(pathinfo($plik['name'], PATHINFO_EXTENSION));
in_array($rozszerzenie, ['jpg', 'jpeg', 'png', 'webp'], true);
getimagesize($plik['tmp_name']) === false;         // to nie jest obrazek

$nazwa = 'avatar_' . $_SESSION['uzytkownik_id'] . '_' . time() . '.' . $rozszerzenie;
move_uploaded_file($plik['tmp_name'], __DIR__ . '/uploads/avatary/' . $nazwa);

Stałe kodów błędów

UPLOAD_ERR_OK (0), UPLOAD_ERR_INI_SIZE (1), UPLOAD_ERR_FORM_SIZE (2), UPLOAD_ERR_PARTIAL (3), UPLOAD_ERR_NO_FILE (4), UPLOAD_ERR_NO_TMP_DIR (6), UPLOAD_ERR_CANT_WRITE (7), UPLOAD_ERR_EXTENSION (8).

Przydatne klucze $_SERVER

$_SERVER['REQUEST_METHOD']   // 'GET' albo 'POST'
$_SERVER['REQUEST_URI']      // /profil.php?id=5
$_SERVER['QUERY_STRING']     // id=5&sort=level
$_SERVER['REMOTE_ADDR']      // adres IP użytkownika
$_SERVER['HTTP_USER_AGENT']  // przeglądarka (dane od klienta!)
$_SERVER['DOCUMENT_ROOT']    // katalog główny serwera

Quiz – sprawdź się

  1. Strona otwarta adresem profil.php?id=7. Co wypisze ten kod i dlaczego?

    function pokazId() {
        echo $_GET['id'] ?? 'brak';
    }
    pokazId();
    
  2. Formularz wygląda tak:

    <form method="post" action="wyslij.php">
        <input type="file" name="foto">
        <button type="submit">Wyślij</button>
    </form>
    

    Użytkownik wybiera pies.jpg i wysyła. Co znajdzie się w $_FILES, a co w $_POST?

  3. Które klucze tablicy $_FILES['avatar'] ustawia PHP, a które przychodzą od przeglądarki użytkownika? Wypisz je w dwóch grupach.

  4. Skrypt dostaje $_FILES['zdjecie']['error'] === 4. Co dokładnie się stało i jaki komunikat powinien zobaczyć użytkownik? A co znaczy sytuacja, w której $_FILES['zdjecie'] w ogóle nie istnieje, choć formularz został wysłany metodą POST?

  5. Znajdź trzy błędy:

    $plik = $_FILES['avatar'];
    if ($plik['type'] === 'image/png') {
        move_uploaded_file($plik['tmp_name'], 'uploads/avatary/' . $plik['name']);
        zapiszAvatarUzytkownika($polaczenie, $id, $plik['name']);
    }
    
  6. Dlaczego ten kod przy pierwszym wejściu na stronę wypisuje „jasny”, mimo że ciasteczko właśnie zostało ustawione?

    setcookie('motyw', 'ciemny', time() + 3600);
    echo $_COOKIE['motyw'] ?? 'jasny';
    
  7. Uzupełnij kod tak, aby pobrał identyfikator kontynentu przesłany metodą GET, a przy jego braku użył wartości 6 (zadanie w stylu arkusza egzaminacyjnego):

    $idKontynentu = ______________________;
    
  8. Który warunek poprawnie sprawdza, że formularz został wysłany metodą POST? Uzasadnij odrzucenie pozostałych.

    a) if ($_POST['login'] != '') { ... }
    b) if ($_SERVER['REQUEST_METHOD'] === 'POST') { ... }
    c) if (isset($_POST)) { ... }
    
  9. W formularzu logowania zamieniono method="post" na method="get". Kod PHP poprawiono, żeby czytał $_GET. Aplikacja działa. Podaj dwa konkretne powody, dla których to zła zmiana.

  10. Skrypt waliduje avatar, zapisuje nazwę pliku w bazie i przekierowuje na index.php — ale ktoś usunął z niego wywołanie move_uploaded_file(). Co zobaczy gracz na stronie głównej i gdzie jest jego obrazek?

  11. Napisz fragment, który odrzuci plik większy niż 1 MB, korzystając ze stałej MAKS_ROZMIAR_AVATARA. Na podstawie którego klucza $_FILES to sprawdzisz i dlaczego nie na podstawie tego, co napisała przeglądarka?

  12. Dlaczego w kolumnie avatar zapisujemy avatar_1_1790000000.png, a nie zawartość pliku ani uploads/avatary/avatar_1_1790000000.png?


Odpowiedzi – quiz „Sprawdź się”

Odpowiedzi
  1. Wypisze 7. $_GET jest superglobalna, więc jest widoczna wewnątrz funkcji bez global i bez przekazywania jej parametrem.
  2. $_FILES będzie puste, a $_POST['foto'] będzie zawierać sam napis pies.jpg. Brakuje enctype="multipart/form-data", więc przeglądarka wysłała tylko nazwę pliku jako zwykły tekst.
  3. PHP ustawia tmp_name, error, size. Od przeglądarki (czyli od użytkownika) pochodzą name i type — tym dwóm nie ufamy.
  4. 4 to UPLOAD_ERR_NO_FILE: użytkownik wysłał formularz, nie wybierając pliku — komunikat w stylu „Wybierz plik z obrazkiem.”. Brak całego klucza $_FILES['zdjecie'] oznacza najczęściej przekroczony post_max_size: PHP odrzuciło całe żądanie, więc $_POST też jest puste. Dlatego czytamy $_FILES['avatar'] ?? null.
  5. (a) sprawdzanie formatu przez type — ten klucz podaje przeglądarka i da się go podmienić; (b) zapis pod nazwą z name — dwóch graczy nadpisze sobie pliki, a plik atak.php stanie się skryptem na serwerze; (c) brak sprawdzenia error, rozmiaru i rzeczywistej zawartości (getimagesize()) — dodatkowo nie sprawdzono, czy move_uploaded_file() zwróciło true, zanim zapisano nazwę w bazie.
  6. setcookie() wysyła nagłówek Set-Cookie do przeglądarki. Tablica $_COOKIE powstaje z ciasteczek przysłanych razem z bieżącym żądaniem, więc nowe ciasteczko pojawi się w niej dopiero przy kolejnym wczytaniu strony.
  7. $idKontynentu = $_GET['id'] ?? 6; (na egzaminie równie dobra jest wersja isset($_GET['id']) ? $_GET['id'] : 6; w TaskQuest dopisalibyśmy rzutowanie: (int)($_GET['id'] ?? 6)).
  8. Poprawne jest b. Wariant a) odwołuje się do $_POST['login'], którego przy wejściu GET nie ma — ostrzeżenie o braku klucza. Wariant c) zawsze daje true, bo tablica $_POST istnieje zawsze (przy GET jest po prostu pusta). Sprawdzenie konkretnego pola przez isset($_POST['login']) też jest poprawne.
  9. Po pierwsze, login i hasło znajdą się w adresie URL: w historii przeglądarki, w zakładkach, w logach serwera, na ekranie przy rzutniku. Po drugie, GET służy do pobierania danych — adres da się skopiować, wysłać i odświeżyć, a w ten sposób nie powinno się uruchamiać logowania ani zmieniać danych. Dochodzi jeszcze limit długości adresu.
  10. Gracz zobaczy uszkodzony obrazek (baza wskazuje plik, którego nie ma w uploads/avatary/). Obrazek był w katalogu tymczasowym i PHP usunęło go po zakończeniu skryptu.
  11. if ($plik['size'] > MAKS_ROZMIAR_AVATARA) {
        $bledy[] = 'Plik może mieć najwyżej 1 MB.';
    }
    
    Klucz size wylicza PHP na podstawie rzeczywiście odebranych bajtów. Gdybyśmy wierzyli deklaracji przeglądarki, wystarczyłoby spreparować żądanie z fałszywym rozmiarem.
  12. Zawartości nie trzymamy w bazie, bo baza obsługuje dane tekstowe i liczbowe, a pliki są od tego, żeby leżeć na dysku i być serwowane przez serwer WWW. Pełnej ścieżki nie zapisujemy, bo powielałaby się w każdym wierszu, a po zmianie nazwy katalogu (np. na media/avatary) trzeba byłoby poprawić wszystkie rekordy. Katalog jest jeden i siedzi w stałej KATALOG_AVATAROW.