Limites de tamanho para upload de imagens em PHP

Diagrama das camadas que podem recusar um upload grande: a caixa de diálogo da galeria, o servidor web, post_max_size, upload_max_filesize e a verificação de 4,000,000 bytes em upload.php

Um "Upload failed" para uma foto grande pode vir de cinco lugares diferentes e, em PHP, dois deles ficam no php.ini e agem antes de o seu script rodar. Esta página lista cada camada na ordem em que a requisição passa por ela, o que cada uma responde e um comportamento do PHP que transforma um 413 limpo em uma página HTML, a menos que você mude uma configuração.

As camadas, em ordem

  1. A caixa de diálogo da galeria. Com maxUploadFileSize: 4000000 nas opções do editor, a caixa de diálogo recusa um arquivo maior antes de enviá-lo. Um arquivo de 4.5 MB resultou em "big.jpg is 4.3 MB. The maximum allowed size is 3.8 MB." (a caixa de diálogo conta em megabytes binários). Isso é uma conveniência. O servidor não pode depender disso.
  2. O servidor web. A documentação do nginx dá a client_max_body_size o padrão de 1m; assim, uma foto de 2 MB é recusada antes de o PHP vê-la, com a página HTML 413 do próprio nginx. O equivalente no Apache é LimitRequestBody. O nginx.conf.example do exemplo define client_max_body_size 5m;.
  3. post_max_size (8M por padrão) limita o corpo inteiro da requisição.
  4. upload_max_filesize (2M por padrão) limita cada arquivo.
  5. A sua própria verificação: 4,000,000 bytes no exemplo, respondida com um 413 em JSON.

Para que o limite do exemplo seja o que vale, todas as camadas anteriores precisam permitir um pouco mais de 4,000,000 bytes. A codificação multipart acrescenta algumas centenas de bytes em volta do arquivo.

php.ini
upload_max_filesize = 5M
post_max_size = 8M
display_errors = Off
log_errors = On

Como o script distingue as camadas

PHP · rte/upload.php
// Over post_max_size, PHP drops the WHOLE body: $_POST and $_FILES are both empty.
$length = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if (empty($_FILES) && empty($_POST) && $length > rte_ini_bytes('post_max_size')) {
    throw new RteHttpError(413, 'The upload is larger than the server accepts (post_max_size).');
}

$file = $_FILES['file'] ?? null;
// One file only: "file[]" arrives as nested arrays, which this refuses.
if (!is_array($file) || !is_int($file['error'] ?? null) || !is_string($file['tmp_name'] ?? null)) {
    throw new RteHttpError(400, 'No file received. Send it as the "file" field.');
}
switch ($file['error']) {
    case UPLOAD_ERR_OK:
        break;
    case UPLOAD_ERR_INI_SIZE:   // larger than upload_max_filesize
    case UPLOAD_ERR_FORM_SIZE:  // larger than a MAX_FILE_SIZE form field
        throw new RteHttpError(413, 'The image is larger than the server accepts.');
    case UPLOAD_ERR_PARTIAL:
        throw new RteHttpError(400, 'The upload was interrupted. Please try again.');
    case UPLOAD_ERR_NO_FILE:
        throw new RteHttpError(400, 'No file received.');
    default:                    // NO_TMP_DIR, CANT_WRITE, EXTENSION: server-side problems
        error_log('rte upload: UPLOAD_ERR code ' . $file['error']);
        throw new RteHttpError(500, 'The server could not store the upload.');
}
if (!is_uploaded_file($file['tmp_name'])) {
    throw new RteHttpError(400, 'No file received.');
}

$size = filesize($file['tmp_name']);
if ($size === false || $size === 0) {
    throw new RteHttpError(400, 'The file is empty.');
}
if ($size > RTE_MAX_BYTES) {
    throw new RteHttpError(413, 'Images can be at most 4,000,000 bytes.');
}

Acima de upload_max_filesize, o PHP ainda executa o script, e $_FILES['file']['error'] é UPLOAD_ERR_INI_SIZE. Enviamos um arquivo de 3 MB a um servidor rodando com o padrão de 2M do PHP e recebemos 413 {"error":"The image is larger than the server accepts."}.

Acima de post_max_size, o PHP descarta o corpo inteiro: $_POST e $_FILES ficam ambos como arrays vazios, como se nada tivesse sido enviado. O script ainda roda, e $_SERVER['CONTENT_LENGTH'] ainda contém o tamanho real. Essa combinação é a única forma de saber o que aconteceu, por isso o exemplo a verifica antes de qualquer outra coisa. Se você pular essa verificação, um upload acima do limite parece um "nenhum arquivo recebido".

A armadilha do display_errors

Quando uma requisição passa de post_max_size, o PHP também emite um warning, e faz isso antes da primeira linha do seu script. Com display_errors ativado, que é o padrão embutido do PHP quando nenhum php.ini é carregado e também a configuração do php.ini-development, esse warning vira o início do corpo da resposta. O seu http_response_code(413) então falha com "Cannot modify header information", e o navegador recebe isto:

Resposta HTTP, upload de 9,000,204 bytes, display_errors ativado
HTTP/1.1 200 OK
Content-type: text/html; charset=UTF-8

<br />
<b>Warning</b>:  POST Content-Length of 9000204 bytes exceeds the limit of 8388608 bytes in <b>Unknown</b> on line <b>0</b><br />
...
{"error":"The upload is larger than the server accepts (post_max_size)."}

Um script não consegue corrigir isso, porque a saída foi enviada antes de o script começar. Desative a exibição de erros e registre-os em log. No servidor embutido do PHP, -d display_errors=stderr não ajudou no nosso teste. Só -d display_errors=0 resultou em um HTTP/1.1 413 limpo com Content-Type: application/json, e o warning foi então para o log do servidor. O php.ini-production já vem com display_errors = Off.

A pasta temporária e o open_basedir

O PHP grava todo upload em um arquivo temporário antes de o seu script rodar, em upload_tmp_dir ou na pasta temporária do sistema. Se você usa open_basedir e deixa essa pasta de fora, todo upload falha, seja qual for o tamanho. O log mostra "open_basedir restriction in effect" e "unable to create a temporary file", e o script vê UPLOAD_ERR_NO_TMP_DIR, que o exemplo responde com um 500. Depois que adicionamos a pasta temporária ao open_basedir, as 35 verificações do teste de contrato passaram. No Windows, -d open_basedir=A;B na linha de comando mantém só A, sem aviso, porque ; inicia um comentário de ini. Coloque o valor entre aspas: -d 'open_basedir="A;B"'.

Teste

Inicie o projeto de exemplo deste guia com php -d upload_max_filesize=5M -d display_errors=0 -d log_errors=1 -S localhost:5201 router.php e envie um arquivo acima do limite do exemplo e outro acima de post_max_size:

Shell
head -c 4500000 /dev/zero > big.jpg
curl -F "file=@big.jpg" http://localhost:5201/rte/upload
{"error":"Images can be at most 4,000,000 bytes."}

head -c 9000000 /dev/zero > huge.jpg
curl -F "file=@huge.jpg" http://localhost:5201/rte/upload
{"error":"The upload is larger than the server accepts (post_max_size)."}

As duas respostas são HTTP 413 com corpo em JSON. Para ver a armadilha, inicie o servidor de novo sem -d display_errors=0 e envie huge.jpg mais uma vez. Em um PHP sem php.ini, ou com o php.ini-development, o warning em HTML chega antes do JSON.

O mesmo guia para outras plataformas

JavaScript · Next.js · ASP.NET Core · MVC 5 · Web Forms

Leia a seguir