Photo de profil, pièce jointe, CV : presque tous les sites permettent d'envoyer des fichiers. En PHP, l'envoi (on dit souvent upload, ou « téléverser ») tient en quelques lignes… et c'est justement là qu'est le danger. Un script mal protégé permet d'envoyer un fichier PHP déguisé en image, puis de l'exécuter sur ton serveur. Dans ce tutoriel, tu vas écrire un upload d'images complet et sécurisé. Durée : environ 40 minutes.

Comment fonctionne un upload en PHP

  1. Le formulaire envoie le fichier avec la requête (il faut l'attribut enctype="multipart/form-data").
  2. PHP le reçoit et le range dans un dossier temporaire. Ses informations sont dans le tableau $_FILES : nom d'origine, taille, chemin temporaire, code d'erreur.
  3. Ton script vérifie le fichier, puis le déplace à son emplacement définitif avec move_uploaded_file(). Sinon, PHP le supprime à la fin de la requête.

Crée un dossier avec index.html et upload.php. Pour tester, lance le serveur intégré de PHP dans ce dossier : php -S localhost:8000, puis ouvre http://localhost:8000.

1. Le formulaire HTML

<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <title>Envoyer une image</title>
</head>
<body>
  <h1>Envoyer une photo</h1>

  <!-- enctype="multipart/form-data" est obligatoire pour envoyer des fichiers. -->
  <form action="upload.php" method="post" enctype="multipart/form-data">
    <label for="photo">Image JPEG, PNG ou WebP, 2 Mo maximum</label>
    <input type="file" id="photo" name="photo" accept="image/jpeg,image/png,image/webp" required>
    <button type="submit">Envoyer</button>
  </form>
</body>
</html>

L'attribut accept filtre les fichiers proposés par le navigateur. C'est un confort pour l'utilisateur, pas une sécurité : n'importe qui peut envoyer une requête sans passer par ton formulaire. Tout doit être vérifié côté serveur.

2. Le script PHP

<?php

declare(strict_types=1);

const MAX_SIZE = 2 * 1024 * 1024; // 2 Mo
const ALLOWED_TYPES = [
    'image/jpeg' => 'jpg',
    'image/png' => 'png',
    'image/webp' => 'webp',
];
const UPLOAD_DIR = __DIR__ . '/uploads/';

function fail(string $message): never
{
    http_response_code(400);
    exit('Erreur : ' . htmlspecialchars($message));
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['photo'])) {
    fail('aucun fichier reçu.');
}

$file = $_FILES['photo'];

// 1. PHP a-t-il bien reçu le fichier ?
if ($file['error'] !== UPLOAD_ERR_OK) {
    fail(match ($file['error']) {
        UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'le fichier est trop lourd.',
        UPLOAD_ERR_NO_FILE => 'aucun fichier choisi.',
        default => 'l\'envoi a échoué, réessaie.',
    });
}

// 2. Taille maximale.
if ($file['size'] > MAX_SIZE) {
    fail('le fichier dépasse 2 Mo.');
}

// 3. Type réel du fichier, lu dans son contenu : l'extension et le type envoyés par le navigateur se falsifient.
$type = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);

if (!isset(ALLOWED_TYPES[$type])) {
    fail('seules les images JPEG, PNG et WebP sont acceptées.');
}

// 4. Nom aléatoire : on ne réutilise jamais le nom choisi par l'utilisateur.
if (!is_dir(UPLOAD_DIR)) {
    mkdir(UPLOAD_DIR, 0755, true);
}

$name = bin2hex(random_bytes(16)) . '.' . ALLOWED_TYPES[$type];

if (!move_uploaded_file($file['tmp_name'], UPLOAD_DIR . $name)) {
    fail('impossible d\'enregistrer le fichier.');
}

echo 'Merci ! Ton image est enregistrée sous le nom ' . htmlspecialchars($name) . '.';

3. Les quatre vérifications, et pourquoi

  • Le code d'erreur : PHP signale un fichier trop lourd, un envoi interrompu ou l'absence de fichier dans $_FILES['photo']['error']. On le vérifie toujours en premier.
  • La taille : 2 Mo ici. Sans limite, un fichier énorme peut remplir ton disque.
  • Le type réel : c'est le point le plus important. Le nom (photo.jpg) et le type envoyé par le navigateur ($_FILES['photo']['type']) sont choisis par l'utilisateur : ils se falsifient. finfo lit le début du fichier pour connaître son vrai type. Un script PHP renommé en .jpg est ainsi refusé.
  • Un nouveau nom : on ne garde jamais le nom d'origine, qui pourrait contenir ../ pour sortir du dossier, écraser un autre fichier, ou finir par .php. random_bytes() génère un nom impossible à deviner, et l'extension est déduite du type réel.

Enfin, htmlspecialchars() échappe tout ce qu'on affiche, et declare(strict_types=1) rend PHP plus strict sur les types.

4. Tester, y compris les cas d'erreur

  • Envoie une vraie image PNG : elle apparaît dans le dossier uploads avec un nom aléatoire.
  • Crée un fichier texte, renomme-le en test.jpg et envoie-le : il doit être refusé.
  • Envoie une image de plus de 2 Mo : message « le fichier est trop lourd ».

En production : trois précautions de plus

  • Range les fichiers hors du dossier public (par exemple un dossier au-dessus de public/), et sers-les avec un script qui vérifie les droits. Ainsi, même un fichier piégé ne peut jamais être exécuté par le serveur web.
  • Règle les limites de PHP dans php.ini, qui s'appliquent avant ton script :
upload_max_filesize = 2M
post_max_size = 8M
  • Pour des images publiques, recrée l'image avec la bibliothèque GD ou Imagick (redimensionnement) : cela supprime aussi les métadonnées, comme la position GPS de la photo.

Pour aller plus loin, tu peux accepter plusieurs fichiers d'un coup (name="photos[]" et l'attribut multiple), ou afficher un aperçu de l'image en JavaScript avant l'envoi.