PHP-Datei-Uploads mit Session-Upload-Fortschritt verfolgen
PHP kann melden, wie viel von einem Upload bereits empfangen wurde, während der Browser die Datei noch sendet. Upload- und Fortschrittsanfragen müssen gleichzeitig laufen können. Eine Fortschrittsmeldung darf niemals das Endergebnis des Uploads ersetzen. Dieses lokale Beispiel akzeptiert eine JPEG-, PNG- oder PDF-Datei bis 10 MiB, zeigt den Fortschritt während einer langsamen Übertragung und bestätigt, wenn PHP die Datei gespeichert hat.
Anforderungen an die Serverkonfiguration
Verwenden Sie Docker mit Linux-Containern und einen aktuellen Browser. Die folgende Konfiguration
verwendet PHP 8.4.26 mit PHP-FPM und Nginx 1.28.3; sie wurde unter Linux mit Chromium 152 getestet.
Erstellen Sie ein neues Verzeichnis namens php-upload-progress mit einem Unterverzeichnis
namens public. Speichern Sie die folgenden vier Konfigurationsdateien in
php-upload-progress; die drei Anwendungsdateien aus den späteren Abschnitten gehören in
public.
Speichern Sie dies als 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;'"]
Speichern Sie dies als php.ini. PHP analysiert den Upload, bevor es
upload.php ausführt. Daher gehören diese Einstellungen in die Konfiguration,
nicht in einen Aufruf von ini_set() innerhalb des Handlers. Der
Fortschrittsschlüssel kombiniert upload_progress_ mit dem Wert des Formularfelds
PHP_SESSION_UPLOAD_PROGRESS.
Das PHP-Handbuch zum Upload-Fortschritt in Sessions
beschreibt diesen Lebenszyklus.
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
Speichern Sie dies als fpm.conf. Vier Worker ermöglichen es, eine
Fortschrittsanfrage zu bearbeiten, während ein anderer Worker den Upload empfängt. Ein einzelner
beschäftigter Worker kann nicht beide Anfragen gleichzeitig bedienen.
[www]
pm = static
pm.max_children = 4
request_terminate_timeout = 120s
Speichern Sie dies als 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;
}
}
}
Die entscheidende Einstellung ist
fastcgi_request_buffering off:
Nginx leitet eingehende Bytes sofort an PHP weiter. fastcgi_buffering steuert Antworten,
und proxy_request_buffering gilt für einen HTTP-Upstream; keine der beiden Optionen ersetzt
diese Einstellung für PHP-FPM. Das Anfragelimit von 12 MiB lässt über dem Dateilimit von 10 MiB
Platz für den Multipart-Overhead. Diese Demo verbindet sich direkt mit Nginx; ein weiterer Proxy,
der Uploads puffert, würde den zwischenzeitlichen Fortschritt verbergen.
Upload-Handler implementieren
Speichern Sie dies als public/upload.php. Der Handler prüft den von PHP gemeldeten
Upload-Fehler, untersucht den MIME-Typ der temporären Datei und speichert akzeptierte Bytes unter
einem zufälligen Namen außerhalb des Webroots. Er verwendet niemals den ursprünglichen Dateinamen
als Pfad. Die Upload-Dokumentation von PHP
erklärt, warum der vom Browser gelieferte MIME-Typ keine Validierung darstellt.
<?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.']);
}
Eine erfolgreiche Antwort bedeutet, dass die Datei in einen privaten Speicher verschoben wurde. Sie bedeutet nicht, dass sich der Inhalt sicher veröffentlichen lässt. Wiederholte akzeptierte Uploads erhalten jeweils eigene zufällige Dateinamen; dieses Beispiel dedupliziert keine Dateien und setzt keine unterbrochene Übertragung fort.
Fortschrittsverfolgung implementieren
Speichern Sie dies als public/progress.php. Ein erster Aufruf ohne ID setzt das
Session-Cookie. Weitere Aufrufe verwenden dasselbe Cookie, um den Eintrag zum Upload zu lesen.
Schließen Sie die Session sofort nach dem Lesen: Die dateibasierten Sessions von PHP sperren Daten,
solange sie geöffnet sind. Das kann andere Anfragen verzögern, die diese Session verwenden.
Siehe 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);
Der Fortschritt misst die Multipart-Anfrage einschließlich ihres Overheads. Wenn
session.upload_progress.cleanup
aktiviert ist, entfernt PHP den Eintrag nach dem Lesen des Anfragekörpers. Ein fehlender Eintrag
kann daher „noch nicht gestartet“ oder „bereits empfangen“ bedeuten; er belegt weder Erfolg noch
Fehlschlag. Nur upload.php meldet, ob Validierung und Speicherung erfolgreich waren.
Clientseitige Integration
Speichern Sie dies als public/index.html. Das versteckte Fortschrittsfeld steht vor der
Datei, damit PHP seine ID sieht, bevor es Datei-Bytes empfängt. Erstellen Sie
FormData, bevor Sie die Steuerelemente deaktivieren, da deaktivierte
Steuerelemente nicht in die Formulardaten aufgenommen werden.
<!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>
Die Seite akzeptiert jeweils einen Upload. Solange dieser läuft, deaktiviert sie die Dateiauswahl
und das Absenden. Der Handler ignoriert außerdem wiederholte Absendeereignisse. Jeder Auftrag hat
eine eigene Fortschritts-ID und einen eigenen Abort-Controller. Beim Abschluss wird dieser Auftrag
ungültig gemacht, bevor das Formular wieder aktiviert wird. Selbst wenn eine Abfrage bereits
response.json() erreicht hat, verhindert die Identitätsprüfung, dass sie die
Rückmeldung zu einem anderen Auftrag ändert. Das Abbrechen von fetch
stoppt zudem unnötige ausstehende Abfragen.
Einen langsamen Upload ausführen
Nachdem Sie alle sieben Dateien gespeichert haben, fügen Sie Folgendes aus dem Verzeichnis ein,
das php-upload-progress enthält. Die Subshell lässt Ihr aktuelles Verzeichnis unverändert,
und die Befehlskette mit && stoppt, wenn der Verzeichniswechsel oder
Build fehlschlägt. Docker benötigt für den ersten Build Netzwerkzugriff. Wählen Sie einen anderen
Host-Port, falls 8080 bereits belegt ist.
(
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
)
Lassen Sie dieses Terminal weiterlaufen und öffnen Sie http://127.0.0.1:8080/. Begrenzen
Sie in den Entwicklertools Ihres Browsers die Upload-Bandbreite auf etwa 256 KiB/s. Wählen Sie dann
eine ungefähr 4 MiB große JPEG-, PNG- oder PDF-Datei und drücken Sie
Upload. Zwischenzeitliche Prozentwerte sollten vor
Upload complete! erscheinen, gefolgt vom generierten Dateinamen
und der Byte-Anzahl. Schnelle lokale Uploads können zwischen zwei Abfragen fertig werden und direkt
zum Abschluss springen.
Wenn der Fortschritt bei einer langsamen Übertragung bei null bleibt, prüfen Sie, ob Cookies
aktiviert sind, die initiale Anfrage an progress.php erfolgreich war und ihr Cookie
bei beiden Anfragen mitgesendet wird. Prüfen Sie anschließend die Worker-Anzahl und die Pufferung
von Anfragen. Eine 413-Antwort kann sowohl von PHP als auch von Nginx stammen; eine Anfrage über dem
Nginx-Limit von 12 MiB erreicht den PHP-Handler nie. Die Auswahl einer umbenannten Textdatei sollte
zu einer Ablehnung wegen des Dateityps führen, da das HTML-Attribut accept
nur ein Hinweis für die Dateiauswahl ist.
Akzeptierte Dateien liegen unter /var/lib/php-upload-demo im laufenden Container. Sie haben
keine öffentliche URL. Stoppen Sie den Container im Vordergrund mit Ctrl+C, wenn Sie fertig sind;
--rm löscht dann seine Uploads und Session-Daten. Nach Änderungen an einer
gespeicherten Quell- oder Konfigurationsdatei muss das Image neu gebaut werden.
Sicherheitshinweise
Dies ist eine Demo ausschließlich für die Loopback-Schnittstelle, ohne Anmeldung, CSRF-Token, Kontingente oder Malware-Scan. Die MIME-Erkennung schränkt Formate ein, beweist aber nicht, dass eine Datei harmlos ist. Halten Sie Inhalte privat, bis Ihre Anwendung die Validierung und Verarbeitung abgeschlossen hat. Eine bereitgestellte Version benötigt außerdem HTTPS, sichere Session-Cookies, Zugriffskontrolle und eine explizite Aufbewahrungsrichtlinie. Stellen Sie diesen Container nicht unverändert als Upload-Dienst bereit.
Browserkompatibilität
Die Seite verwendet fetch, FormData,
AbortController und crypto.randomUUID() ohne Polyfills.
randomUUID() erfordert einen sicheren Kontext:
HTTP über Loopback funktioniert für diese Demo; der Fernzugriff erfordert HTTPS. Cookies müssen
aktiviert sein. Wenn Sie die Seite neu laden oder schließen, endet ihre Rückmeldung. Diese
Implementierung bietet keine Schaltfläche zum Pausieren oder Fortsetzen.
