Suivre les téléversements PHP avec la progression en session
PHP peut indiquer quelle part d’un téléversement il a reçue pendant que le navigateur envoie encore le fichier. Les requêtes de téléversement et de progression doivent s’exécuter simultanément, et une lecture de progression ne doit jamais remplacer le résultat final du téléversement. Cet exemple local accepte un fichier JPEG, PNG ou PDF jusqu’à 10 MiB, affiche la progression pendant un transfert lent et confirme quand PHP a stocké le fichier.
Prérequis de configuration du serveur
Utilisez Docker avec des conteneurs Linux et un navigateur récent. La configuration ci-dessous utilise
PHP 8.4.26 avec PHP-FPM et Nginx 1.28.3 ; elle a été testée sous Linux avec Chromium 152. Créez un
nouveau répertoire nommé php-upload-progress, avec un sous-répertoire public. Enregistrez les quatre fichiers de
configuration suivants dans php-upload-progress ; les trois fichiers d’application des sections suivantes vont
dans public.
Enregistrez ceci sous Dockerfile :
FROM php:8.4.26-fpm-alpine3.23
RUN apk add --no-cache nginx=1.28.3-r7 \
&& mkdir -p /var/lib/php-upload-demo /var/lib/php-upload-sessions \
&& chown www-data:www-data /var/lib/php-upload-demo /var/lib/php-upload-sessions \
&& chmod 0700 /var/lib/php-upload-demo /var/lib/php-upload-sessions
COPY php.ini /usr/local/etc/php/conf.d/zz-upload.ini
COPY fpm.conf /usr/local/etc/php-fpm.d/zz-upload.conf
COPY nginx.conf /etc/nginx/nginx.conf
COPY public/ /app/public/
CMD ["sh", "-c", "php-fpm -D && exec nginx -g 'daemon off;'"]
Enregistrez ceci sous php.ini. PHP analyse le téléversement avant d’exécuter upload.php, ces paramètres
doivent donc figurer dans la configuration, et non dans un appel à ini_set() au sein du gestionnaire.
La clé de progression combine upload_progress_ avec la valeur du champ PHP_SESSION_UPLOAD_PROGRESS du formulaire.
Le manuel PHP sur la progression de téléversement en session
décrit ce cycle de vie.
session.save_handler = files
session.save_path = /var/lib/php-upload-sessions
session.name = PHPUPLOADDEMO
session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_httponly = 1
session.cookie_samesite = Strict
session.upload_progress.enabled = On
session.upload_progress.cleanup = On
session.upload_progress.prefix = "upload_progress_"
session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS"
session.upload_progress.freq = "1%"
session.upload_progress.min_freq = 0.1
upload_max_filesize = 10M
post_max_size = 12M
max_input_time = 120
log_errors = On
display_errors = Off
Enregistrez ceci sous fpm.conf. Quatre workers permettent à une requête de progression de s’exécuter
pendant qu’un autre worker reçoit le téléversement. Un worker unique occupé ne peut pas servir les
deux requêtes à la fois.
[www]
pm = static
pm.max_children = 4
request_terminate_timeout = 120s
Enregistrez ceci sous nginx.conf :
user www-data;
worker_processes 1;
error_log /dev/stderr warn;
events { worker_connections 128; }
http {
include /etc/nginx/mime.types;
access_log /dev/stdout;
server {
listen 8080;
root /app/public;
index index.html;
client_max_body_size 12m;
location / { try_files $uri $uri/ =404; }
location ~ \.php$ {
try_files $uri =404;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass 127.0.0.1:9000;
fastcgi_request_buffering off;
fastcgi_read_timeout 120s;
}
}
}
Le paramètre essentiel est
fastcgi_request_buffering off :
Nginx transmet immédiatement les octets entrants à PHP. fastcgi_buffering contrôle les réponses, et
proxy_request_buffering s’applique à un upstream HTTP ; aucun des deux ne remplace ce paramètre pour PHP-FPM.
La limite de requête de 12 MiB laisse de la place pour le surcoût multipart au-delà de la limite de
fichier de 10 MiB. Cette démo se connecte directement à Nginx ; un autre proxy qui met les
téléversements en mémoire tampon masquerait la progression intermédiaire.
Implémentation du gestionnaire de téléversement
Enregistrez ceci sous public/upload.php. Il vérifie l’erreur de téléversement PHP, inspecte le type MIME du
fichier temporaire et stocke les octets acceptés sous un nom aléatoire en dehors de la racine web. Il
n’utilise jamais le nom de fichier d’origine comme chemin. La
documentation PHP sur les téléversements
explique pourquoi le type MIME fourni par le navigateur ne constitue pas une validation.
<?php
// public/upload.php
declare(strict_types=1);
function reply(int $status, array $body): never {
http_response_code($status);
header('Content-Type: application/json');
header('Cache-Control: no-store');
echo json_encode($body, JSON_THROW_ON_ERROR);
exit;
}
try {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
reply(405, ['error' => 'Use POST to upload a file.']);
}
$file = $_FILES['file'] ?? null;
if (!is_array($file) || !isset($file['error'], $file['size'], $file['tmp_name']) ||
!is_int($file['error']) || !is_int($file['size']) || !is_string($file['tmp_name'])) {
reply(400, ['error' => 'Choose one file.']);
}
if ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['size'] > 10 * 1024 * 1024) {
reply(413, ['error' => 'The file exceeds 10 MiB.']);
}
if ($file['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'])) {
reply(400, ['error' => 'PHP did not receive a complete file.']);
}
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'application/pdf' => 'pdf'];
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!isset($extensions[$mime])) {
reply(415, ['error' => 'Choose a JPEG, PNG, or PDF.']);
}
$directory = '/var/lib/php-upload-demo';
if (!is_dir($directory) || !is_writable($directory)) {
throw new RuntimeException('Private storage is unavailable');
}
$filename = bin2hex(random_bytes(16)) . '.' . $extensions[$mime];
$destination = $directory . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $destination)) {
throw new RuntimeException('Could not store upload');
}
reply(201, ['success' => true, 'filename' => $filename, 'size' => $file['size']]);
} catch (Throwable $error) {
error_log('Upload failed: ' . get_class($error));
reply(500, ['error' => 'The upload service is unavailable.']);
}
Une réponse réussie signifie que le fichier a été déplacé vers un stockage privé. Elle ne signifie pas que le contenu peut être publié sans risque. Des téléversements acceptés répétés reçoivent des noms de fichiers aléatoires distincts ; cet exemple ne déduplique pas les fichiers et ne reprend pas un transfert interrompu.
Implémentation du suivi de progression
Enregistrez ceci sous public/progress.php. Un premier appel sans identifiant établit le cookie de session.
Les appels suivants utilisent le même cookie pour lire l’entrée du téléversement. Fermez la session
immédiatement après l’avoir lue : les sessions fichier de PHP verrouillent les données tant qu’elles
sont ouvertes, ce qui peut retarder d’autres requêtes utilisant cette session. Consultez
session_write_close().
<?php
// public/progress.php
declare(strict_types=1);
header('Content-Type: application/json');
$id = $_GET['id'] ?? '';
if (!is_string($id) || ($id !== '' && !preg_match('/\A[0-9a-f-]{36}\z/', $id))) {
http_response_code(400);
echo json_encode(['error' => 'Invalid progress ID.']);
exit;
}
if (!session_start()) {
http_response_code(503);
echo json_encode(['error' => 'Tracking is unavailable.']);
exit;
}
$current = $_SESSION['upload_progress_' . $id] ?? null;
session_write_close();
header('Cache-Control: no-store');
echo json_encode(['progress' => $current === null ? null : [
'loaded' => $current['bytes_processed'],
'total' => $current['content_length'],
]], JSON_THROW_ON_ERROR);
La progression mesure la requête multipart, surcoût compris. Lorsque
session.upload_progress.cleanup
est activé, PHP supprime l’entrée après avoir lu le corps de la requête. Une entrée absente peut donc
signifier « non démarré » ou « déjà reçu » ; elle ne permet d’établir ni un succès ni un échec. Seul
upload.php indique si la validation et le stockage ont réussi.
Intégration côté client
Enregistrez ceci sous public/index.html. Le champ de progression masqué précède le fichier afin que PHP voie
son identifiant avant de recevoir les octets du fichier. Construisez FormData avant de désactiver les
contrôles, car les contrôles désactivés sont exclus des données du formulaire.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PHP upload progress</title>
</head>
<body>
<h1>Upload a JPEG, PNG, or PDF</h1>
<form id="upload-form">
<fieldset id="controls">
<legend>One file, up to 10 MiB</legend>
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" id="progress-id" />
<label for="file">File</label>
<input id="file" type="file" name="file" accept="image/jpeg,image/png,application/pdf" required />
<button type="submit">Upload</button>
</fieldset>
</form>
<label for="progress">Request received by PHP</label>
<progress id="progress" value="0" max="100"></progress>
<p id="status" role="status">Choose a file.</p>
<script>
const form = document.getElementById('upload-form')
const controls = document.getElementById('controls')
const progressId = document.getElementById('progress-id')
const bar = document.getElementById('progress')
const status = document.getElementById('status')
let activeJob = null
async function poll(job) {
try {
const response = await fetch(`progress.php?id=${job.id}`, {
cache: 'no-store',
signal: job.controller.signal,
})
if (!response.ok) throw new Error('Progress request failed')
const { progress } = await response.json()
// An old response can arrive after completion or during the next upload.
if (activeJob !== job) return
if (progress && progress.total > 0) {
const percentage = Math.min(100, Math.floor(100 * progress.loaded / progress.total))
bar.value = percentage
status.textContent = percentage === 100
? 'Request received; waiting for server confirmation.'
: `Uploading: ${percentage}%`
}
} catch {
if (activeJob === job) {
status.textContent = 'Progress unavailable; waiting for the upload response.'
}
}
// Schedule after this request settles, so polls never overlap.
if (activeJob === job) job.timer = setTimeout(() => poll(job), 500)
}
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (activeJob !== null) return
const job = { id: crypto.randomUUID(), controller: new AbortController(), timer: null }
progressId.value = job.id
const body = new FormData(form)
activeJob = job
controls.disabled = true
bar.value = 0
status.textContent = 'Starting upload…'
let message = 'Upload could not be confirmed. Check the server before retrying.'
let stored = false
try {
const session = await fetch('progress.php', { cache: 'no-store' })
if (!session.ok) throw new Error('Session initialization failed')
job.timer = setTimeout(() => poll(job), 500)
const response = await fetch('upload.php', { method: 'POST', body })
if (!response.ok) {
message = response.status === 413 ? 'The file exceeds the upload size limit.'
: response.status === 415 ? 'Choose a JPEG, PNG, or PDF.'
: response.status >= 500 ? 'The upload service is unavailable.'
: 'The upload was rejected. Choose a file and try again.'
throw new Error('Upload rejected')
}
const result = await response.json()
if (result.success !== true) throw new Error('Missing storage confirmation')
stored = true
message = `Upload complete! Stored as ${result.filename} (${result.size} bytes).`
} catch {
// A network failure can occur after storage; do not claim a safe automatic retry.
} finally {
activeJob = null
clearTimeout(job.timer)
job.controller.abort()
controls.disabled = false
status.textContent = message
if (stored) bar.value = 100
}
})
</script>
</body>
</html>
La page accepte un seul téléversement à la fois. Elle désactive la sélection de fichier et l’envoi
tant que ce téléversement est en cours, et le gestionnaire ignore aussi les événements submit répétés.
Chaque tâche possède son propre identifiant de progression et son propre contrôleur d’abandon.
L’achèvement invalide cette tâche avant de réactiver le formulaire. Même si une requête de suivi a
déjà atteint response.json(), la vérification d’identité l’empêche de modifier les informations affichées pour
une autre tâche. Interrompre fetch
arrête aussi le travail inutile des requêtes de suivi en attente.
Lancer un téléversement lent
Après avoir enregistré les sept fichiers, collez ceci depuis le répertoire contenant php-upload-progress.
Le sous-shell laisse votre répertoire courant inchangé, et la chaîne && s’arrête si le changement
de répertoire ou la construction échoue. Docker a besoin d’un accès réseau pour la première
construction. Choisissez un autre port hôte si le port 8080 est déjà occupé.
(
cd php-upload-progress &&
docker build -t php-upload-progress . &&
docker run --rm --name php-upload-progress \
--publish 127.0.0.1:8080:8080 php-upload-progress
)
Laissez ce terminal en cours d’exécution et ouvrez http://127.0.0.1:8080/. Dans les outils de développement de votre
navigateur, définissez une limite de bande passante montante d’environ 256 KiB/s, puis choisissez un
fichier JPEG, PNG ou PDF d’environ 4 MiB et appuyez sur Upload. Des
pourcentages intermédiaires devraient s’afficher avant
Upload complete!, suivis du nom de fichier généré et du nombre
d’octets. Les téléversements locaux rapides peuvent se terminer entre deux requêtes de suivi et
passer directement à l’achèvement.
Si la progression reste à zéro pendant un transfert lent, vérifiez que les cookies sont activés, que
la requête d’amorçage progress.php a réussi et que son cookie accompagne les deux requêtes. Vérifiez
ensuite le nombre de workers et la mise en mémoire tampon des requêtes. Une réponse 413 peut provenir
de PHP comme de Nginx ; une requête dépassant la limite de 12 MiB de Nginx n’atteint jamais le
gestionnaire PHP. Choisir un fichier texte renommé devrait entraîner un rejet de type, car l’attribut
HTML accept n’est qu’une indication pour le sélecteur de fichiers.
Les fichiers acceptés se trouvent dans /var/lib/php-upload-demo à l’intérieur du conteneur en cours d’exécution. Ils
n’ont pas d’URL publique. Une fois terminé, arrêtez le conteneur au premier plan avec Ctrl+C ;
--rm supprime alors ses téléversements et ses données de session. Il est nécessaire de reconstruire
l’image après avoir modifié un fichier source ou de configuration enregistré.
Mises en garde de sécurité
Il s’agit d’une démonstration limitée à l’interface de bouclage (loopback), sans connexion, jeton CSRF, quotas ni analyse antimalware. La détection MIME restreint les formats mais ne prouve pas qu’un fichier est inoffensif. Gardez le contenu privé jusqu’à ce que la validation et le traitement de votre application soient terminés. Une version déployée nécessite aussi HTTPS, des cookies de session sécurisés, un contrôle d’accès et une politique de conservation explicite ; n’exposez pas ce conteneur tel quel comme service de téléversement.
Compatibilité des navigateurs
La page utilise fetch, FormData, AbortController et crypto.randomUUID() sans polyfills.
randomUUID() nécessite un contexte sécurisé :
le HTTP en boucle locale fonctionne pour cette démo ; une utilisation distante nécessite HTTPS. Les
cookies doivent être activés. Recharger ou fermer la page fait perdre les informations de progression
affichées, et cette implémentation ne propose aucun bouton de pause ou de reprise.
