Téléversements de fichiers Flask : validation et stockage privé
Pour téléverser un fichier avec Flask, envoyez un formulaire multipart, validez la requête et enregistrez le flux téléversé. Ce tutoriel vous fournit un formulaire local dans le navigateur qui accepte les fichiers JPEG, PNG et PDF et les stocke en dehors du répertoire statique public. Vous vérifierez aussi les octets enregistrés et verrez ce qui se passe lorsqu’un téléversement est rejeté.
Configurer votre environnement Flask
Utilisez Linux avec Bash, Python 3.14, la prise en charge des environnements virtuels et libmagic installé. Les instructions d’installation de python-magic couvrent la bibliothèque système ; installer uniquement le paquet Python n’installe pas cette bibliothèque. L’exemple utilise Flask 3.1.3, Flask-WTF 1.3.0 et python-magic 0.4.27.
Depuis un répertoire où vous conservez vos projets, exécutez :
mkdir flask-upload-demo &&
cd flask-upload-demo &&
python3 -m venv .venv &&
.venv/bin/python -m pip install Flask==3.1.3 Flask-WTF==1.3.0 python-magic==0.4.27 &&
mkdir templates
Les commandes s’arrêtent si le projet existe déjà ou si une étape de configuration échoue.
Continuez seulement après leur réussite, avec votre terminal toujours dans
flask-upload-demo. Créez-y les deux fichiers suivants.
Créer le formulaire de téléversement de fichiers
Enregistrez ceci sous templates/upload.html :
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Upload a file</title>
</head>
<body>
<h1>Upload a file</h1>
<p>Choose a JPEG, PNG, or PDF. The whole request must fit within 16 MiB.</p>
{% for message in get_flashed_messages() %}
<p role="status">{{ message }}</p>
{% endfor %}
{% if error %}<p role="alert">{{ error }}</p>{% endif %}
<form method="post" enctype="multipart/form-data" novalidate>
{{ form.hidden_tag() }}
{{ form.file.label }} {{ form.file }}
{% for errors in form.errors.values() %}
{% for message in errors %}
<p role="alert">{{ message }}</p>
{% endfor %}
{% endfor %}
<button type="submit">Upload</button>
</form>
</body>
</html>
L’encodage multipart transporte les octets du fichier. hidden_tag() affiche le
jeton CSRF que Flask-WTF vérifie par rapport à la session du navigateur.
novalidate vous permet d’essayer la réponse du serveur en cas de fichier
manquant, au lieu que le navigateur bloque d’abord l’envoi. La classe du formulaire se trouve
ci-dessous dans app.py.
Implémenter la gestion des téléversements de fichiers
Enregistrez cette application complète sous app.py :
import os
import tempfile
from pathlib import Path
import magic
from flask import Flask, flash, redirect, render_template, request, url_for
from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed, FileField, FileRequired
from werkzeug.exceptions import RequestEntityTooLarge
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ['FLASK_SECRET_KEY']
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024
upload_directory = Path(app.instance_path) / 'uploads'
upload_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
ALLOWED_TYPES = {
'image/jpeg': ('jpg', 'jpeg'),
'image/png': ('png',),
'application/pdf': ('pdf',),
}
class UploadForm(FlaskForm):
file = FileField('File', validators=[
FileRequired('Choose a file.'),
FileAllowed(['jpg', 'jpeg', 'png', 'pdf'], 'Choose a JPEG, PNG, or PDF.'),
])
def save_upload(file):
extension = file.filename.rsplit('.', 1)[-1].lower()
detected_type = magic.from_buffer(file.read(2048), mime=True)
file.seek(0)
extensions = ALLOWED_TYPES.get(detected_type)
if extensions is None or extension not in extensions:
raise ValueError('The file contents do not match an allowed file type and extension.')
# The client filename never becomes a storage path.
target = tempfile.NamedTemporaryFile(
dir=upload_directory, prefix='upload-', suffix='.' + extensions[0], delete=False,
)
try:
with target:
file.save(target)
except OSError:
Path(target.name).unlink(missing_ok=True)
raise
return Path(target.name).name
@app.errorhandler(RequestEntityTooLarge)
def too_large(error):
# Do not parse the rejected request again while rendering its error page.
form = UploadForm(formdata=None)
return render_template(
'upload.html', form=form,
error='Upload request is too large. Choose a smaller file.',
), 413
@app.route('/', methods=['GET', 'POST'])
def upload():
form = UploadForm()
if request.method == 'GET':
return render_template('upload.html', form=form)
if not form.validate_on_submit():
return render_template('upload.html', form=form), 400
try:
filename = save_upload(form.file.data)
except ValueError:
return render_template(
'upload.html', form=form,
error='The file contents do not match an allowed file type and extension.',
), 400
except (OSError, magic.MagicException) as error:
app.logger.error('Upload failed (%s)', type(error).__name__)
return render_template(
'upload.html', form=form,
error='The file could not be saved. Please try again.',
), 500
flash(f'Saved as {filename}.')
return redirect(url_for('upload'), code=303)
Les validateurs de fichiers de Flask-WTF vérifient qu’un fichier a été sélectionné et que son extension est autorisée. La fonction utilitaire utilise ensuite libmagic pour inspecter les 2 048 premiers octets, en suivant les recommandations de python-magic, puis rembobine le flux avant de l’enregistrer. Sans ce rembobinage, le fichier stocké perdrait les octets déjà lus. Le type MIME annoncé par le navigateur n’est pas utilisé pour cette décision.
Ces contrôles rejettent les incohérences évidentes, comme un fichier texte renommé en
.png. Ils ne décodent pas entièrement le fichier, ne détectent pas toute
corruption et ne prouvent pas que son contenu est inoffensif. Gardez les fichiers téléversés privés
tant que l’analyse ou le traitement propre au format dont votre application a besoin n’a pas réussi.
NamedTemporaryFile crée un nouveau nom à chaque téléversement accepté, avec des
permissions de fichier limitées à l’utilisateur créateur sous Linux. Même deux fichiers appelés
photo.png donnent deux fichiers distincts. Malgré le nom de la fonction,
delete=False conserve les téléversements réussis dans
instance/uploads jusqu’à ce que vous les supprimiez. Une erreur d’écriture ou de
fermeture interceptée déclenche le nettoyage du fichier partiel de cette tentative ; un processus
tué ou un système de fichiers qui refuse la suppression peut tout de même laisser un fichier.
Exécuter le formulaire et vérifier un téléversement
Dans le même répertoire de projet, générez un secret de session local et démarrez Flask :
FLASK_SECRET_KEY="$(.venv/bin/python -c 'import secrets; print(secrets.token_hex(32))')" &&
export FLASK_SECRET_KEY &&
.venv/bin/python -m flask --app app run --host 127.0.0.1 --port 5000
Ouvrez http://127.0.0.1:5000/, sélectionnez un petit fichier JPEG, PNG ou PDF avec
File, puis cliquez sur Upload.
La page renvoyée affiche Saved as suivi du nom de
fichier généré. Un POST réussi redirige vers un GET : actualiser cette page de résultat n’envoie donc
pas à nouveau le fichier. Soumettre délibérément le formulaire une nouvelle fois crée une autre copie.
Dans un autre terminal, placez-vous dans le répertoire du projet et comparez votre fichier d’origine au fichier enregistré. Remplacez les deux chemins d’exemple, en utilisant le nom généré affiché sur la page :
cmp -- '/path/to/photo.png' 'instance/uploads/upload-example.png'
Aucune sortie et un code de sortie zéro signifient que les octets sont identiques. Le fichier
enregistré se trouve en dehors du répertoire static de Flask, et cette
application n’a pas de route de téléchargement. Gardez le répertoire d’instance hors de tout
mappage d’un serveur web public.
Arrêtez Flask avec Ctrl+C. Pour redémarrer, exécutez à nouveau la commande de démarrage depuis le répertoire du projet. Elle génère un nouveau secret : rechargez donc tout formulaire ouvert avant de le soumettre. Si le port 5000 est occupé, choisissez un port libre dans la commande et dans l’URL du navigateur. Ce serveur de développement reste sur l’interface de bouclage ; l’application n’a ni connexion, ni quota de stockage, ni politique de suppression automatique.
Gestion des erreurs et validation
Essayez ces cas en surveillant le statut du POST dans le panneau Réseau du navigateur :
| Entrée ou condition | Réponse attendue | Résultat stocké |
|---|---|---|
| Soumettre sans sélectionner de fichier | 400 et Choose a file. | Aucun nouveau fichier |
Sélectionner un fichier .txt | 400 et Choose a JPEG, PNG, or PDF. | Aucun nouveau fichier |
Renommer un texte brut en .png, ou soumettre un .png vide | 400 et un message d’incohérence entre contenu et type | Aucun nouveau fichier |
| Sélectionner un fichier de 16 MiB ou plus | 413 et Upload request is too large. Choose a smaller file. | Aucun nouveau fichier |
Supprimer l’input masqué csrf_token dans les outils de développement du navigateur avant la soumission | 400 et The CSRF token is missing. | Aucun nouveau fichier |
| Téléverser alors que le répertoire de stockage n’est pas accessible en écriture | 500 et The file could not be saved. Please try again. | Aucun nouveau fichier si la création a échoué |
La limite de requête inclut les champs multipart et les séparateurs : le plus grand fichier accepté est donc légèrement inférieur à 16 MiB. Flask limite aussi la taille et le nombre des champs multipart. Sa documentation sur les téléversements indique que, avec le serveur de développement, certains téléversements peuvent se terminer par une réinitialisation de la connexion au lieu d’afficher une réponse 413.
En cas d’erreur CSRF, rechargez la page et sélectionnez à nouveau le fichier. Gardez le secret stable si vous exécutez plusieurs processus d’application : ils doivent s’accorder sur les signatures de session et de jeton. La protection CSRF valide le jeton de session du formulaire ; elle n’authentifie pas la personne qui téléverse. En cas d’échec du stockage, seul le type de l’exception est journalisé, ce qui laisse les chemins locaux et les détails de diagnostic hors de la page.
Si vous avez besoin d’une API de téléversement de fichiers Flask
Cet exemple renvoie du HTML et exige le cookie de session du navigateur ainsi que le jeton CSRF. Une simple requête cURL multipart échoue donc à la validation du formulaire. Pour une API distincte, déterminez qui peut téléverser, comment les clients s’authentifient et quelles réponses JSON ils gèrent avant de réutiliser la logique de validation et de stockage. Une API authentifiée par cookie nécessite toujours une protection CSRF ; changer le format de réponse ne supprime pas cette exigence.
Gérer les téléversements de gros fichiers
Si vous placez plus tard l’application derrière Nginx, sa directive
client_max_body_size
peut rejeter la requête avant que Flask ne la voie. Alignez les limites du proxy et de
l’application, et gérez les erreurs aux deux niveaux. Relever uniquement la limite du proxy ne peut
pas contourner la limite de 16 MiB de Flask.
Pour des téléversements reprenables, utilisez un protocole tel que tus avec un serveur et un client compatibles. Le formulaire présenté ici envoie une seule requête multipart ; découper un fichier en morceaux dans le navigateur exigerait une autre implémentation serveur pour les suivre et les assembler.
Le traitement en arrière-plan est une étape distincte, après la réception d’un fichier. Un worker peut traiter les octets stockés, mais cela ne rend pas ce formulaire reprenable et ne contourne pas la limite de requête. Cet exemple local s’arrête à l’enregistrement du fichier. Si le traitement devient lent par la suite, le guide Celery de Flask explique la configuration supplémentaire du worker et du broker ; vous aurez aussi besoin d’une politique pour les tâches en échec et les fichiers téléversés conservés.
Avant de rendre les téléversements publics
Choisissez une politique de conservation pour instance/uploads avant d’accumuler de
vraies données d’utilisateurs. Ajoutez l’authentification, l’autorisation et les quotas par
utilisateur, ainsi que les contrôles de contenu nécessaires à vos formats de fichier, avant
d’accepter du trafic public ou d’exposer des téléchargements. Ces décisions relèvent de
l’application qui utilise les fichiers téléversés ; le formulaire local vous offre un endroit où les
tester.
