Upload de arquivos no Flask com validação e armazenamento privado
Para fazer upload de um arquivo com o Flask, envie um formulário multipart, valide a requisição e salve o stream recebido. Este passo a passo cria um formulário local no navegador que aceita arquivos JPEG, PNG e PDF e os armazena fora do diretório estático público. Você também vai verificar os bytes salvos e ver o que acontece quando um upload é rejeitado.
Configurando seu ambiente Flask
Use Linux com Bash, Python 3.14, suporte a ambientes virtuais e a libmagic instalada. As instruções de instalação do python-magic cobrem a biblioteca do sistema; instalar apenas o pacote Python não a instala. O exemplo usa Flask 3.1.3, Flask-WTF 1.3.0 e python-magic 0.4.27.
A partir de um diretório onde você guarda seus projetos, execute:
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
Os comandos são interrompidos se o projeto já existir ou se alguma etapa de configuração falhar.
Continue somente depois que eles forem concluídos com sucesso, com o terminal ainda em
flask-upload-demo. Crie ali os dois arquivos a seguir.
Criando o formulário de upload de arquivos
Salve isto como 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>
A codificação multipart transporta os bytes do arquivo. hidden_tag() renderiza o token CSRF que
o Flask-WTF confere com a sessão do navegador. novalidate permite testar a resposta do servidor
para arquivo ausente, em vez de deixar o navegador bloquear o envio antes. A classe do formulário
está em app.py, mais abaixo.
Implementando o tratamento de uploads de arquivos
Salve esta aplicação completa como 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)
Os validadores de arquivo do Flask-WTF verificam se um arquivo foi selecionado e se a extensão dele é permitida. Em seguida, a função auxiliar usa a libmagic para inspecionar os primeiros 2.048 bytes, seguindo as orientações do python-magic, e volta o stream ao início antes de salvá-lo. Sem esse retorno ao início, o arquivo armazenado perderia os bytes já lidos. O tipo MIME declarado pelo navegador não é usado nessa decisão.
Essas verificações rejeitam incompatibilidades óbvias, como um arquivo de texto renomeado para
.png. Elas não decodificam o arquivo por completo, não detectam todo tipo de corrupção
nem provam que o conteúdo é inofensivo. Mantenha os uploads privados até que qualquer varredura ou
processamento específico de formato de que sua aplicação precise tenha sido concluído com sucesso.
NamedTemporaryFile cria um nome novo a cada upload aceito e, no Linux, o arquivo recebe permissões
restritas ao usuário que o criou. Até dois uploads chamados photo.png geram arquivos
separados. Apesar do nome da função, delete=False mantém os uploads bem-sucedidos em
instance/uploads até que você os remova. Um erro de escrita ou de fechamento capturado aciona a limpeza
do arquivo parcial daquela tentativa; um processo encerrado à força ou um sistema de arquivos que
recuse a exclusão ainda pode deixar um arquivo para trás.
Execute o formulário e verifique um upload
No mesmo diretório do projeto, gere um segredo de sessão local e inicie o 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
Abra http://127.0.0.1:5000/, selecione um JPEG, PNG ou PDF pequeno em
File e clique em Upload.
A página retornada mostra Saved as seguido do nome de
arquivo gerado. Um POST bem-sucedido redireciona para um GET, então atualizar essa página de
resultado não envia o arquivo de novo. Enviar o formulário novamente de propósito cria outra cópia.
Em outro terminal, entre no diretório do projeto e compare o arquivo original com o salvo. Substitua os dois caminhos de exemplo, usando o nome gerado mostrado na página:
cmp -- '/path/to/photo.png' 'instance/uploads/upload-example.png'
Se o comando não imprimir nada e terminar com status de saída zero, os bytes são idênticos. O
arquivo salvo fica fora do diretório static do Flask, e este app não tem rota de download.
Mantenha o diretório de instância fora de qualquer mapeamento de servidor web público.
Pare o Flask com Ctrl+C. Para reiniciar, execute novamente o comando de inicialização no diretório do projeto. Ele gera um novo segredo, então recarregue qualquer formulário aberto antes de enviá-lo. Se a porta 5000 estiver ocupada, escolha uma porta livre no comando e na URL do navegador. Este servidor de desenvolvimento fica restrito à interface de loopback; o app não tem login, cota de armazenamento nem política de exclusão automática.
Tratamento de erros e validação
Teste estes casos acompanhando o status do POST no painel Rede do navegador:
| Entrada ou condição | Resposta esperada | Resultado armazenado |
|---|---|---|
| Enviar sem selecionar um arquivo | 400 e Choose a file. | Nenhum arquivo novo |
Selecionar um arquivo .txt | 400 e Choose a JPEG, PNG, or PDF. | Nenhum arquivo novo |
Renomear texto simples para .png ou enviar um .png vazio | 400 e uma mensagem de incompatibilidade de conteúdo/tipo | Nenhum arquivo novo |
| Selecionar um arquivo de 16 MiB ou mais | 413 e Upload request is too large. Choose a smaller file. | Nenhum arquivo novo |
Remover o campo oculto csrf_token nas ferramentas de desenvolvedor do navegador antes de enviar | 400 e The CSRF token is missing. | Nenhum arquivo novo |
| Fazer upload quando o diretório de armazenamento não permite escrita | 500 e The file could not be saved. Please try again. | Nenhum arquivo novo se a criação tiver falhado |
O limite de requisição inclui os campos e os delimitadores multipart, então o maior arquivo aceito é um pouco menor que 16 MiB. O Flask também limita o tamanho e a quantidade de campos multipart. A documentação de uploads do Flask observa que alguns uploads no servidor de desenvolvimento podem terminar com a conexão redefinida em vez de exibir uma resposta 413.
Em caso de erros de CSRF, recarregue a página e selecione o arquivo novamente. Mantenha o segredo estável ao executar vários processos da aplicação: eles precisam concordar nas assinaturas de sessão e de token. A proteção CSRF valida o token de sessão do formulário; ela não autentica quem faz o upload. Falhas de armazenamento registram em log apenas o tipo da exceção, deixando caminhos locais e detalhes de diagnóstico fora da página.
Se você precisar de uma API de upload de arquivos no Flask
Este exemplo retorna HTML e exige o cookie de sessão e o token CSRF do navegador. Por isso, uma requisição multipart simples com cURL falha na validação do formulário. Para uma API separada, defina quem pode fazer upload, como os clientes se autenticam e quais respostas JSON eles tratam antes de reutilizar a lógica de validação e armazenamento. Uma API autenticada por cookie continua precisando de proteção CSRF; mudar o formato da resposta não elimina essa exigência.
Lidando com uploads de arquivos grandes
Se depois você colocar o app atrás do Nginx, o
client_max_body_size
do Nginx pode rejeitar a requisição antes que o Flask a receba. Alinhe os limites do proxy e da
aplicação e trate os erros nas duas camadas. Aumentar apenas o limite do proxy não substitui o
limite de 16 MiB do Flask.
Para uploads retomáveis, use um protocolo como o tus com um servidor e um cliente compatíveis. O formulário deste guia envia uma única requisição multipart; dividir um arquivo em partes no navegador exigiria outra implementação de servidor para rastrear e montar essas partes.
O processamento em segundo plano é uma etapa separada, posterior ao recebimento do arquivo. Um worker pode processar os bytes armazenados, mas não torna este formulário retomável nem contorna o limite de requisição. Este exemplo local termina quando o arquivo é salvo. Se o processamento ficar lento mais tarde, o guia de Celery do Flask explica a configuração adicional de worker e broker; você também vai precisar de uma política para tarefas com falha e uploads retidos.
Antes de tornar os uploads públicos
Escolha uma política de retenção para instance/uploads antes de acumular dados reais de usuários.
Adicione autenticação, autorização e cotas por usuário e as verificações de conteúdo necessárias
para seus formatos de arquivo antes de aceitar tráfego público ou expor downloads. Essas decisões
cabem à aplicação que usa os arquivos enviados; o formulário local oferece um lugar para testá-las.
