Upload de arquivos parece simples — e na superfície é. Mas quando você considera segurança, validação real do tipo do arquivo, limites de tamanho, nomes de arquivo maliciosos, armazenamento organizado e suporte a múltiplos arquivos, o assunto revela profundidade considerável. Neste artigo construímos um sistema de upload robusto do zero.
Como o upload funciona
Quando um formulário com enctype="multipart/form-data" é submetido com arquivos, o PHP recebe os dados na superglobal $_FILES. Cada arquivo chega como um array com cinco campos:
<?php
// $_FILES['arquivo'] contém:
[
'name' => 'foto.jpg', // Nome original no computador do usuário
'type' => 'image/jpeg', // MIME type declarado pelo navegador (NÃO confie nisso)
'tmp_name' => '/tmp/php8HgZ3x', // Caminho temporário onde o PHP salvou
'error' => UPLOAD_ERR_OK, // Código de erro (0 = sem erro)
'size' => 245678, // Tamanho em bytes
]
Validação completa
<?php
declare(strict_types=1);
class ValidadorDeUpload
{
private array $erros = [];
private const TIPOS_PERMITIDOS = [
'image/jpeg' => ['jpg', 'jpeg'],
'image/png' => ['png'],
'image/webp' => ['webp'],
'image/gif' => ['gif'],
];
private const TAMANHO_MAXIMO = 5 * 1024 * 1024; // 5MB
public function validar(array $arquivo): bool
{
$this->erros = [];
// 1. Verificar código de erro do PHP
if ($arquivo['error'] !== UPLOAD_ERR_OK) {
$this->erros[] = match ($arquivo['error']) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'Arquivo excede o tamanho máximo permitido.',
UPLOAD_ERR_PARTIAL => 'O arquivo foi enviado parcialmente.',
UPLOAD_ERR_NO_FILE => 'Nenhum arquivo foi selecionado.',
// Estes três não são culpa de quem enviou: são defeito de
// configuração do servidor. Vale separar, porque um
// "erro desconhecido" genérico faz a equipe procurar no
// lugar errado — e estes precisam ir para o log, não para a tela.
UPLOAD_ERR_NO_TMP_DIR => 'Servidor sem diretório temporário configurado.',
UPLOAD_ERR_CANT_WRITE => 'Servidor não conseguiu gravar em disco.',
UPLOAD_ERR_EXTENSION => 'Uma extensão do PHP interrompeu o upload.',
default => 'Erro desconhecido no upload.',
};
return false;
}
// 2. Verificar se é um upload legítimo (não um arquivo local do servidor)
if (!is_uploaded_file($arquivo['tmp_name'])) {
$this->erros[] = 'Arquivo inválido.';
return false;
}
// 3. Verificar tamanho
if ($arquivo['size'] > self::TAMANHO_MAXIMO) {
$this->erros[] = 'Arquivo excede 5MB.';
return false;
}
// 4. Verificar MIME type REAL com finfo (não confiar no $_FILES['type'])
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeReal = $finfo->file($arquivo['tmp_name']);
if (!array_key_exists($mimeReal, self::TIPOS_PERMITIDOS)) {
$this->erros[] = "Tipo de arquivo não permitido: {$mimeReal}";
return false;
}
// 5. Verificar extensão contra o MIME real (dupla verificação)
$extensao = strtolower(pathinfo($arquivo['name'], PATHINFO_EXTENSION));
if (!in_array($extensao, self::TIPOS_PERMITIDOS[$mimeReal], strict: true)) {
$this->erros[] = 'Extensão não corresponde ao tipo do arquivo.';
return false;
}
return true;
}
public function erros(): array { return $this->erros; }
}
Armazenamento seguro
<?php
declare(strict_types=1);
class ServicoDeUpload
{
public function __construct(
private readonly string $diretorioBase,
private readonly ValidadorDeUpload $validador,
) {
if (!is_dir($this->diretorioBase)) {
mkdir($this->diretorioBase, 0755, recursive: true);
$this->protegerDiretorio($this->diretorioBase);
}
}
/**
* Validar MIME e extensão NÃO impede que o arquivo carregue código.
* Um GIF de algumas dezenas de bytes com "<?php ... ?>" colado no fim
* continua sendo image/gif para o finfo, continua sendo imagem válida
* para o getimagesize(), e passa inteiro pela validação da classe
* anterior. O que decide o desfecho não é a validação, é onde o
* arquivo vai parar: se aquele diretório for interpretado pelo servidor,
* o código roda ao ser acessado pela URL.
*
* O lugar certo para os uploads é FORA do document root, entregues por
* um script que lê o arquivo e devolve o Content-Type. Quando isso não
* for possível, negue a execução no próprio diretório.
*/
private function protegerDiretorio(string $diretorio): void
{
// Vale para Apache com AllowOverride ligado. Em nginx não existe
// .htaccess: a mesma regra vai no bloco server da configuração.
$regras = <<<'CONF'
# Nada aqui dentro é executável.
php_flag engine off
RemoveHandler .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
RemoveType .php .phtml .php3 .php4 .php5 .php7 .php8 .phar
Options -Indexes -ExecCGI
CONF;
file_put_contents($diretorio . '/.htaccess', $regras . "\n");
}
public function salvar(array $arquivo, string $subdiretorio = ''): string
{
if (!$this->validador->validar($arquivo)) {
throw new InvalidArgumentException(
implode(', ', $this->validador->erros())
);
}
// NUNCA use o nome original do usuário — pode conter ../../../etc/passwd
$extensao = strtolower(pathinfo($arquivo['name'], PATHINFO_EXTENSION));
$nomeSeguro = bin2hex(random_bytes(16)) . '.' . $extensao; // Nome aleatório
$diretorio = rtrim($this->diretorioBase . '/' . ltrim($subdiretorio, '/'), '/');
if (!is_dir($diretorio)) {
mkdir($diretorio, 0755, recursive: true);
}
$destino = $diretorio . '/' . $nomeSeguro;
// move_uploaded_file: move o arquivo temporário para o destino final
// Mais seguro que rename() pois verifica se é um upload legítimo
if (!move_uploaded_file($arquivo['tmp_name'], $destino)) {
throw new RuntimeException('Falha ao salvar o arquivo.');
}
// Retorna o caminho relativo ao diretorioBase
return ltrim($subdiretorio . '/' . $nomeSeguro, '/');
}
}
// Uso:
$servico = new ServicoDeUpload(
diretorioBase: __DIR__ . '/storage/uploads',
validador: new ValidadorDeUpload(),
);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['foto'])) {
try {
$caminho = $servico->salvar($_FILES['foto'], 'fotos/perfil');
echo "Arquivo salvo em: {$caminho}";
} catch (InvalidArgumentException $e) {
echo "Erro de validação: " . $e->getMessage();
}
}
Upload múltiplo
<!-- name="fotos[]" permite enviar múltiplos arquivos -->
<input type="file" name="fotos[]" multiple accept="image/*">
<?php
// $_FILES['fotos'] com múltiplos arquivos chega num formato diferente
// Precisamos reorganizar para iterar normalmente
function reorganizarArquivos(array $arquivos): array
{
$resultado = [];
foreach ($arquivos['name'] as $i => $nome) {
$resultado[] = [
'name' => $nome,
'type' => $arquivos['type'][$i],
'tmp_name' => $arquivos['tmp_name'][$i],
'error' => $arquivos['error'][$i],
'size' => $arquivos['size'][$i],
];
}
return $resultado;
}
if (isset($_FILES['fotos'])) {
$arquivos = reorganizarArquivos($_FILES['fotos']);
$salvos = [];
$erros = [];
foreach ($arquivos as $i => $arquivo) {
try {
$salvos[] = $servico->salvar($arquivo, 'galeria');
} catch (InvalidArgumentException $e) {
$erros[] = "Arquivo " . ($i + 1) . ": " . $e->getMessage();
}
}
}
Upload é a única funcionalidade em que o usuário coloca um arquivo escolhido por ele dentro do seu servidor, e é por isso que a lista de cuidados é tão longa. Trocar o nome original por um gerado no servidor resolve travessia de diretório e colisão de nomes de uma vez; conferir o tipo pelos bytes, com finfo, vale mais do que qualquer coisa que o navegador declare — o accept do formulário é conveniência visual, não barreira.
Ainda assim, nenhuma validação fecha o caso sozinha: um GIF válido pode carregar código colado no fim e continuar sendo um GIF para toda verificação de conteúdo. O que impede o estrago não é a checagem, é o destino — arquivo de usuário fora do diretório público, ou ao menos num diretório que o servidor se recuse a interpretar. E lembre que gravar em disco não participa da transação do banco: escolha de que lado prefere errar.
Fontes e leituras recomendadas
- PHP Manual — Upload de arquivos: https://www.php.net/manual/pt_BR/features.file-upload.php
- PHP Manual — finfo: https://www.php.net/manual/pt_BR/class.finfo.php
- OWASP — File Upload Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- PHP Manual —
move_uploaded_file, a unica forma segura de tirar o arquivo do diretorio temporario: https://www.php.net/manual/pt_BR/function.move-uploaded-file.php - PHP Manual — os codigos de erro de upload, incluindo o silencioso
UPLOAD_ERR_INI_SIZE: https://www.php.net/manual/pt_BR/features.file-upload.errors.php - PHP Manual —
upload_max_filesizeepost_max_size, que precisam ser ajustados juntos: https://www.php.net/manual/pt_BR/ini.core.php
Exercícios
Exercício 1
Implemente upload de avatar de usuário: salve o caminho no banco, exiba no perfil, limite a 2MB e aceite apenas JPEG, PNG e WebP.
Ver resposta
✓ Resposta: Três defesas sustentam o upload, e nenhuma delas é o accept do formulário: o tipo vem do finfo (bytes), o nome é gerado pelo servidor, e o diretório de destino fica fora do public/. Validar por $_FILES['type'] ou pela extensão do nome é como conferir identidade perguntando o nome — é exatamente assim que upload de foto vira shell remoto.
<?php
// ============ schema (trecho) ============
// ALTER TABLE usuarios
// ADD COLUMN avatar_arquivo VARCHAR(64) NULL AFTER email,
// ADD COLUMN avatar_atualizado_em DATETIME NULL;
//
// Guarda-se só o NOME do arquivo, não o caminho absoluto nem a URL: o dia que
// o storage mudar de lugar, é uma linha de configuração — não um UPDATE em
// 40 mil linhas.
// ---------------------------------------- src/Upload/FalhaNoUpload.php
declare(strict_types=1);
namespace App\Upload;
use RuntimeException;
final class FalhaNoUpload extends RuntimeException
{
public static function doCodigoPhp(int $codigo): self
{
// As mensagens do PHP são numéricas; traduzi-las aqui evita espalhar
// `if ($erro === 1)` pelo controller.
$mensagens = [
UPLOAD_ERR_INI_SIZE => 'Arquivo maior que o upload_max_filesize do servidor.',
UPLOAD_ERR_FORM_SIZE => 'Arquivo maior que o limite declarado no formulário.',
UPLOAD_ERR_PARTIAL => 'O envio foi interrompido antes de terminar.',
UPLOAD_ERR_NO_FILE => 'Nenhum arquivo foi enviado.',
UPLOAD_ERR_NO_TMP_DIR => 'O servidor está sem diretório temporário.',
UPLOAD_ERR_CANT_WRITE => 'O servidor não conseguiu gravar o arquivo.',
UPLOAD_ERR_EXTENSION => 'Uma extensão do PHP bloqueou este upload.',
];
return new self($mensagens[$codigo] ?? "Falha desconhecida no upload ({$codigo}).");
}
}
// ---------------------------------------- src/Upload/ServicoAvatar.php
declare(strict_types=1);
namespace App\Upload;
use PDO;
use finfo;
final class ServicoAvatar
{
private const TAMANHO_MAXIMO = 2 * 1024 * 1024; // 2 MB
/**
* MIME real → extensão. A chave é o que o finfo lê dos BYTES do arquivo,
* não o que o navegador declarou em $_FILES['type'] — esse campo é texto
* enviado pelo cliente e mente sempre que o cliente quiser.
*/
private const TIPOS_ACEITOS = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
public function __construct(
private readonly PDO $pdo,
/** Diretório FORA do public/ — nada aqui pode ser executado pelo servidor web. */
private readonly string $diretorio,
) {}
/**
* @param array{name:string,type:string,tmp_name:string,error:int,size:int} $arquivo
* @return string nome do arquivo gravado
*/
public function trocar(int $usuarioId, array $arquivo): string
{
if ($arquivo['error'] !== UPLOAD_ERR_OK) {
throw FalhaNoUpload::doCodigoPhp($arquivo['error']);
}
// Sem esta linha, um caminho forjado em $_POST poderia convencer o
// serviço a "mover" /etc/passwd para dentro do diretório de avatares.
if (!is_uploaded_file($arquivo['tmp_name'])) {
throw new FalhaNoUpload('A origem não é um upload HTTP.');
}
if ($arquivo['size'] > self::TAMANHO_MAXIMO) {
throw new FalhaNoUpload(sprintf(
'O avatar tem %s — o limite é 2 MB.',
number_format($arquivo['size'] / 1048576, 1, ',', '.') . ' MB',
));
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($arquivo['tmp_name']);
if (!is_string($mime) || !isset(self::TIPOS_ACEITOS[$mime])) {
throw new FalhaNoUpload('Formato não aceito. Envie JPEG, PNG ou WebP.');
}
// O finfo acerta o MIME de um PNG com payload PHP colado no fim —
// ele continua sendo um PNG válido. Quem impede o estrago é o
// diretório sem execução, mais o nome gerado abaixo.
if (@getimagesize($arquivo['tmp_name']) === false) {
throw new FalhaNoUpload('O arquivo não é uma imagem legível.');
}
// Nome novo e aleatório. O nome original é dado do usuário e pode
// trazer ../, byte nulo ou o clássico "foto.php.jpg".
$nome = bin2hex(random_bytes(16)) . '.' . self::TIPOS_ACEITOS[$mime];
$destino = $this->diretorio . '/' . $nome;
if (!move_uploaded_file($arquivo['tmp_name'], $destino)) {
throw new FalhaNoUpload('Não foi possível gravar o avatar.');
}
chmod($destino, 0644);
$anterior = $this->arquivoAtual($usuarioId);
$stmt = $this->pdo->prepare(
'UPDATE usuarios
SET avatar_arquivo = :arquivo, avatar_atualizado_em = NOW()
WHERE id = :id'
);
$stmt->execute([':arquivo' => $nome, ':id' => $usuarioId]);
// Só apaga o antigo DEPOIS que o banco confirmou o novo. Na ordem
// inversa, um UPDATE que falha deixa o usuário sem avatar nenhum.
if ($anterior !== null && $anterior !== $nome) {
@unlink($this->diretorio . '/' . $anterior);
}
return $nome;
}
public function arquivoAtual(int $usuarioId): ?string
{
$stmt = $this->pdo->prepare('SELECT avatar_arquivo FROM usuarios WHERE id = :id');
$stmt->execute([':id' => $usuarioId]);
$valor = $stmt->fetchColumn();
return is_string($valor) && $valor !== '' ? $valor : null;
}
}
// ---------------------------------------- public/avatar.php (entrega)
declare(strict_types=1);
// O diretório está fora do public/, então a imagem é servida por rota.
// Vantagem: dá para checar permissão antes de entregar o byte.
$diretorio = __DIR__ . '/../storage/avatares';
// basename() corta qualquer ../ que venha na query string.
$pedido = basename((string) ($_GET['a'] ?? ''));
// Lista branca no formato do nome: 32 hexadecimais + extensão conhecida.
if (!preg_match('/^[0-9a-f]{32}\.(jpg|png|webp)$/', $pedido)) {
http_response_code(404);
exit;
}
$caminho = $diretorio . '/' . $pedido;
if (!is_file($caminho)) {
http_response_code(404);
exit;
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($caminho);
header('Content-Type: ' . $mime);
header('Content-Length: ' . filesize($caminho));
// O nome é aleatório e imutável: pode cachear à vontade.
header('Cache-Control: public, max-age=31536000, immutable');
// Impede o navegador de "adivinhar" que aquilo é HTML e renderizar.
header('X-Content-Type-Options: nosniff');
readfile($caminho);
// ---------------------------------------- public/perfil.php (controller)
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Upload\FalhaNoUpload;
use App\Upload\ServicoAvatar;
session_start();
$pdo = require __DIR__ . '/../config/pdo.php';
$servico = new ServicoAvatar($pdo, __DIR__ . '/../storage/avatares');
$usuarioId = (int) ($_SESSION['usuario_id'] ?? 0);
$erro = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $usuarioId > 0) {
try {
$servico->trocar($usuarioId, $_FILES['avatar'] ?? ['error' => UPLOAD_ERR_NO_FILE]);
// PRG: redireciona depois do POST para o F5 não reenviar o arquivo.
header('Location: /perfil.php?ok=1', true, 303);
exit;
} catch (FalhaNoUpload $e) {
$erro = $e->getMessage();
}
}
$avatar = $servico->arquivoAtual($usuarioId);
require __DIR__ . '/../templates/perfil.php';
// ---------------------------------------- templates/perfil.php (view)
?>
<h1>Meu perfil</h1>
<img src="/avatar.php?a=<?= htmlspecialchars($avatar ?? '', ENT_QUOTES, 'UTF-8') ?>"
alt="Foto do perfil" width="120" height="120">
<?php if ($erro !== null): ?>
<p class="erro"><?= htmlspecialchars($erro, ENT_QUOTES, 'UTF-8') ?></p>
<?php endif; ?>
<form method="post" enctype="multipart/form-data">
<!-- MAX_FILE_SIZE é uma dica ao navegador, nunca uma defesa: economiza a
espera de subir 40 MB para só então ouvir "grande demais". -->
<input type="hidden" name="MAX_FILE_SIZE" value="2097152">
<label for="avatar">Novo avatar (JPEG, PNG ou WebP, até 2 MB)</label>
<input type="file" id="avatar" name="avatar" required
accept="image/jpeg,image/png,image/webp">
<button type="submit">Enviar</button>
</form>
Exercício 2
Adicione redimensionamento de imagem usando a extensão GD: após o upload, crie uma versão thumbnail (150x150) e uma versão média (800x600) mantendo proporção.
Ver resposta
✓ Resposta: Dois detalhes separam o resultado bonito do borrado. imagecopyresampled interpola os pixels vizinhos; imagecopyresized apenas descarta pixels — é mais rápido e o resultado é serrilhado. E "150x150 mantendo proporção" é contraditório: ou se recorta o centro (o que este código faz) ou sobram tarjas. O que nunca se faz é esticar para o quadrado.
<?php
// ---------------------------------------- src/Imagem/Redimensionador.php
declare(strict_types=1);
namespace App\Imagem;
use GdImage;
use RuntimeException;
final class Redimensionador
{
/**
* A GD trabalha descomprimida: ~4 bytes por pixel, independente do
* tamanho do ARQUIVO. Um PNG de 300 KB com 12000x12000 vira 576 MB de RAM
* e derruba o processo antes de qualquer redimensionamento — por isso a
* checagem vem de getimagesize(), antes de abrir.
*/
private const PIXELS_MAXIMOS = 40_000_000;
public function __construct(private readonly int $qualidade = 82) {}
public function abrir(string $caminho): GdImage
{
$info = @getimagesize($caminho);
if ($info === false) {
throw new RuntimeException('Arquivo não é uma imagem legível.');
}
[$largura, $altura] = $info;
if ($largura * $altura > self::PIXELS_MAXIMOS) {
throw new RuntimeException(
"Imagem grande demais: {$largura}x{$altura} pixels."
);
}
$imagem = match ($info[2]) {
IMAGETYPE_JPEG => imagecreatefromjpeg($caminho),
IMAGETYPE_PNG => imagecreatefrompng($caminho),
IMAGETYPE_WEBP => imagecreatefromwebp($caminho),
default => throw new RuntimeException('Formato não suportado.'),
};
if ($imagem === false) {
throw new RuntimeException('A GD não conseguiu decodificar a imagem.');
}
// PNG de 8 bits vem com paleta; sem converter, o resample devolve
// uma imagem manchada. Em truecolor o cálculo de cor é exato.
if (!imageistruecolor($imagem)) {
imagepalettetotruecolor($imagem);
}
return $info[2] === IMAGETYPE_JPEG
? $this->corrigirOrientacao($imagem, $caminho)
: $imagem;
}
/** Cabe dentro da caixa, sem distorcer e sem cortar. Nunca amplia. */
public function conterEm(GdImage $origem, int $larguraMax, int $alturaMax): GdImage
{
$largura = imagesx($origem);
$altura = imagesy($origem);
// min() com 1.0: ampliar uma foto pequena só produz borrão.
$escala = min($larguraMax / $largura, $alturaMax / $altura, 1.0);
$novaLargura = max(1, (int) round($largura * $escala));
$novaAltura = max(1, (int) round($altura * $escala));
$destino = $this->telaTransparente($novaLargura, $novaAltura);
imagecopyresampled(
$destino, $origem,
0, 0, 0, 0,
$novaLargura, $novaAltura, $largura, $altura,
);
return $destino;
}
/** Quadrado exato: recorta o centro e depois reduz. Não distorce. */
public function recortarQuadrado(GdImage $origem, int $lado): GdImage
{
$largura = imagesx($origem);
$altura = imagesy($origem);
$corte = min($largura, $altura);
// intdiv() em vez de /2: coordenada de pixel é inteiro.
$x = intdiv($largura - $corte, 2);
$y = intdiv($altura - $corte, 2);
$destino = $this->telaTransparente($lado, $lado);
imagecopyresampled(
$destino, $origem,
0, 0, $x, $y,
$lado, $lado, $corte, $corte,
);
return $destino;
}
public function salvar(GdImage $imagem, string $caminho, string $formato): void
{
$ok = match ($formato) {
'jpg', 'jpeg' => imagejpeg($imagem, $caminho, $this->qualidade),
'png' => imagepng($imagem, $caminho, 6),
'webp' => imagewebp($imagem, $caminho, $this->qualidade),
default => throw new RuntimeException("Formato inválido: {$formato}"),
};
if (!$ok) {
throw new RuntimeException("Falha ao gravar {$caminho}");
}
}
private function telaTransparente(int $largura, int $altura): GdImage
{
$tela = imagecreatetruecolor($largura, $altura);
// A ordem importa: desliga a mistura, pinta o fundo transparente e
// só então liga o salvamento do canal alfa. Fora dessa ordem, PNG e
// WebP com transparência saem com fundo preto.
imagealphablending($tela, false);
imagesavealpha($tela, true);
imagefill($tela, 0, 0, imagecolorallocatealpha($tela, 0, 0, 0, 127));
imagealphablending($tela, true);
return $tela;
}
/** Foto de celular guarda a rotação no EXIF; sem isso ela sai deitada. */
private function corrigirOrientacao(GdImage $imagem, string $caminho): GdImage
{
if (!function_exists('exif_read_data')) {
return $imagem;
}
$exif = @exif_read_data($caminho);
$giro = match ((int) ($exif['Orientation'] ?? 1)) {
3 => 180,
6 => -90,
8 => 90,
default => 0,
};
if ($giro === 0) {
return $imagem;
}
$girada = imagerotate($imagem, $giro, 0);
imagedestroy($imagem);
return $girada;
}
}
// ---------------------------------------- src/Imagem/GeradorDeVersoes.php
declare(strict_types=1);
namespace App\Imagem;
final class GeradorDeVersoes
{
private const VERSOES = [
'thumb' => ['modo' => 'quadrado', 'largura' => 150, 'altura' => 150],
'media' => ['modo' => 'conter', 'largura' => 800, 'altura' => 600],
];
public function __construct(
private readonly Redimensionador $redimensionador,
private readonly string $diretorio,
) {}
/**
* @return array<string, string> versão → nome do arquivo gerado
*/
public function gerar(string $arquivoOriginal): array
{
$original = $this->redimensionador->abrir($this->diretorio . '/' . $arquivoOriginal);
// O nome das versões deriva do original: thumb do "abc.jpg" é
// "abc-thumb.webp". Nada de novo random — a versão tem de ser
// recalculável a partir do original, e o navegador pode cachear.
$base = pathinfo($arquivoOriginal, PATHINFO_FILENAME);
$saida = [];
try {
foreach (self::VERSOES as $nome => $spec) {
$versao = $spec['modo'] === 'quadrado'
? $this->redimensionador->recortarQuadrado($original, $spec['largura'])
: $this->redimensionador->conterEm($original, $spec['largura'], $spec['altura']);
// WebP em todas as versões derivadas: menor que JPEG na mesma
// qualidade e com transparência, ao contrário do JPEG.
$arquivo = "{$base}-{$nome}.webp";
try {
$this->redimensionador->salvar($versao, $this->diretorio . '/' . $arquivo, 'webp');
$saida[$nome] = $arquivo;
} finally {
imagedestroy($versao);
}
}
} finally {
// Sem destruir, cada upload deixa dezenas de MB presos até o fim
// do request — e num worker de fila, até o fim do processo.
imagedestroy($original);
}
return $saida;
}
}
// ---------------------------------------- uso
declare(strict_types=1);
$gerador = new App\Imagem\GeradorDeVersoes(
new App\Imagem\Redimensionador(qualidade: 82),
__DIR__ . '/../storage/avatares',
);
$versoes = $gerador->gerar('7f3c9e1a2b4d6f8a0c2e4f6a8b0d2e4f.jpg');
// ['thumb' => '7f3c...-thumb.webp', 'media' => '7f3c...-media.webp']
Exercício 3
Implemente upload múltiplo de documentos PDF (máx. 10MB cada, máx. 5 arquivos por vez). Armazene os metadados (nome original, tamanho, data) no banco.
Ver resposta
✓ Resposta: O $_FILES de campo múltiplo vem transposto — cinco arrays paralelos, não uma lista de arquivos — e é aí que quase todo mundo tropeça. A armadilha mais cruel, porém, é o post_max_size: quando o envio inteiro o estoura, $_POST e $_FILES chegam vazios e nenhum UPLOAD_ERR_* é preenchido — a página parece só "não ter feito nada". O único vestígio é o CONTENT_LENGTH.
<?php
// ============ schema ============
// CREATE TABLE documentos (
// id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
// usuario_id BIGINT UNSIGNED NOT NULL,
// nome_original VARCHAR(255) NOT NULL,
// arquivo VARCHAR(64) NOT NULL UNIQUE,
// tamanho_bytes INT UNSIGNED NOT NULL,
// enviado_em DATETIME NOT NULL,
// CONSTRAINT fk_documentos_usuario FOREIGN KEY (usuario_id)
// REFERENCES usuarios (id) ON DELETE RESTRICT
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
//
// ON DELETE RESTRICT, não CASCADE: apagar um usuário não pode varrer em
// silêncio os documentos que ele enviou — isso é decisão de negócio, tomada
// por um caso de uso que também remove os arquivos do disco.
// ---------------------------------------- src/Upload/ColecaoDeArquivos.php
declare(strict_types=1);
namespace App\Upload;
final class ColecaoDeArquivos
{
/**
* O $_FILES de um campo `name="docs[]"` vem TRANSPOSTO — cinco arrays
* paralelos em vez de um array de arquivos:
*
* $_FILES['docs']['name'] = ['a.pdf', 'b.pdf']
* $_FILES['docs']['tmp_name'] = ['/tmp/php1', '/tmp/php2']
*
* Este método devolve o formato que qualquer pessoa esperava:
*
* [['name' => 'a.pdf', 'tmp_name' => '/tmp/php1', ...], [...]]
*
* @return list<array{name:string,type:string,tmp_name:string,error:int,size:int}>
*/
public static function normalizar(array $campo): array
{
if (!is_array($campo['name'])) {
return [$campo];
}
$arquivos = [];
foreach (array_keys($campo['name']) as $i) {
// Slot vazio de um input múltiplo não é erro — é campo em branco.
if ($campo['error'][$i] === UPLOAD_ERR_NO_FILE) {
continue;
}
$arquivos[] = [
'name' => (string) $campo['name'][$i],
'type' => (string) $campo['type'][$i],
'tmp_name' => (string) $campo['tmp_name'][$i],
'error' => (int) $campo['error'][$i],
'size' => (int) $campo['size'][$i],
];
}
return $arquivos;
}
/**
* Quando o POST estoura o post_max_size, o PHP descarta o corpo inteiro:
* $_POST e $_FILES chegam VAZIOS e nenhum código de erro é preenchido.
* O único vestígio é o Content-Length maior que o limite.
*/
public static function postEstourouOLimite(): bool
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $_POST !== [] || $_FILES !== []) {
return false;
}
$limite = self::emBytes((string) ini_get('post_max_size'));
return $limite > 0 && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $limite;
}
private static function emBytes(string $valor): int
{
$numero = (int) $valor;
return match (strtolower(substr(trim($valor), -1))) {
'g' => $numero * 1024 ** 3,
'm' => $numero * 1024 ** 2,
'k' => $numero * 1024,
default => $numero,
};
}
}
// ---------------------------------------- src/Upload/ServicoDeDocumentos.php
declare(strict_types=1);
namespace App\Upload;
use PDO;
use Throwable;
use finfo;
final class ServicoDeDocumentos
{
private const TAMANHO_MAXIMO = 10 * 1024 * 1024; // 10 MB por arquivo
private const QUANTIDADE_MAXIMA = 5;
public function __construct(
private readonly PDO $pdo,
private readonly string $diretorio,
) {}
/**
* @return array{gravados: list<string>, recusados: array<string, string>}
*/
public function enviar(int $usuarioId, array $campo): array
{
if (ColecaoDeArquivos::postEstourouOLimite()) {
throw new FalhaNoUpload(
'O envio inteiro passou do limite do servidor (post_max_size).'
);
}
$arquivos = ColecaoDeArquivos::normalizar($campo);
if ($arquivos === []) {
throw new FalhaNoUpload('Nenhum arquivo foi enviado.');
}
if (count($arquivos) > self::QUANTIDADE_MAXIMA) {
throw new FalhaNoUpload(sprintf(
'São no máximo %d arquivos por envio; vieram %d.',
self::QUANTIDADE_MAXIMA,
count($arquivos),
));
}
$validos = [];
$recusados = [];
foreach ($arquivos as $arquivo) {
try {
$validos[] = $this->validar($arquivo);
} catch (FalhaNoUpload $e) {
$recusados[$arquivo['name']] = $e->getMessage();
}
}
return ['gravados' => $this->persistir($usuarioId, $validos), 'recusados' => $recusados];
}
/** @return array{nome:string, arquivo:string, origem:string, tamanho:int} */
private function validar(array $arquivo): array
{
if ($arquivo['error'] !== UPLOAD_ERR_OK) {
throw FalhaNoUpload::doCodigoPhp($arquivo['error']);
}
if (!is_uploaded_file($arquivo['tmp_name'])) {
throw new FalhaNoUpload('A origem não é um upload HTTP.');
}
if ($arquivo['size'] > self::TAMANHO_MAXIMO) {
throw new FalhaNoUpload('Passa de 10 MB.');
}
if ($arquivo['size'] === 0) {
throw new FalhaNoUpload('O arquivo está vazio.');
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($arquivo['tmp_name']);
if ($mime !== 'application/pdf') {
throw new FalhaNoUpload("Não é um PDF (detectado: {$mime}).");
}
// Cinturão e suspensório: o finfo já leu a assinatura, mas conferir
// os cinco primeiros bytes custa nada e documenta a expectativa.
$inicio = (string) file_get_contents($arquivo['tmp_name'], length: 5);
if (!str_starts_with($inicio, '%PDF-')) {
throw new FalhaNoUpload('O conteúdo não começa com a assinatura de PDF.');
}
return [
// O nome ORIGINAL é metadado exibido ao usuário, nunca caminho.
// Vai para o banco como texto e sai escapado na view.
'nome' => mb_substr($arquivo['name'], 0, 255),
'arquivo' => bin2hex(random_bytes(16)) . '.pdf',
'origem' => $arquivo['tmp_name'],
'tamanho' => $arquivo['size'],
];
}
/**
* @param list<array{nome:string,arquivo:string,origem:string,tamanho:int}> $validos
* @return list<string>
*/
private function persistir(int $usuarioId, array $validos): array
{
if ($validos === []) {
return [];
}
$movidos = [];
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare(
'INSERT INTO documentos
(usuario_id, nome_original, arquivo, tamanho_bytes, enviado_em)
VALUES (:usuario_id, :nome, :arquivo, :tamanho, NOW())'
);
foreach ($validos as $item) {
$caminho = $this->diretorio . '/' . $item['arquivo'];
if (!move_uploaded_file($item['origem'], $caminho)) {
throw new FalhaNoUpload("Falha ao gravar {$item['nome']}.");
}
$movidos[] = $caminho;
$stmt->execute([
':usuario_id' => $usuarioId,
':nome' => $item['nome'],
':arquivo' => $item['arquivo'],
':tamanho' => $item['tamanho'],
]);
}
$this->pdo->commit();
} catch (Throwable $e) {
$this->pdo->rollBack();
// O rollback desfaz o banco, mas não o disco: os arquivos já
// movidos viram lixo órfão se ninguém os apagar aqui.
foreach ($movidos as $caminho) {
@unlink($caminho);
}
throw $e;
}
return array_column($validos, 'arquivo');
}
}
// ---------------------------------------- public/documentos.php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Upload\FalhaNoUpload;
use App\Upload\ServicoDeDocumentos;
session_start();
$pdo = require __DIR__ . '/../config/pdo.php';
$servico = new ServicoDeDocumentos($pdo, __DIR__ . '/../storage/documentos');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$resultado = $servico->enviar(
(int) $_SESSION['usuario_id'],
$_FILES['documentos'] ?? ['name' => [], 'error' => []],
);
$_SESSION['flash'] = sprintf(
'%d documento(s) enviado(s).%s',
count($resultado['gravados']),
$resultado['recusados'] === []
? ''
: ' Recusados: ' . implode('; ', array_map(
static fn (string $nome, string $motivo): string => "{$nome} ({$motivo})",
array_keys($resultado['recusados']),
$resultado['recusados'],
)),
);
} catch (FalhaNoUpload $e) {
$_SESSION['flash'] = $e->getMessage();
}
header('Location: /documentos.php', true, 303);
exit;
}
Exercício 4
Desafio: implemente armazenamento em dois backends intercambiáveis via interface StorageInterface: LocalStorage (disco) e S3Storage (simulado com arquivo de log). O ServicoDeUpload deve receber o backend por injeção de dependência.
Ver resposta
✓ Resposta: A injeção de dependência aqui não é cerimônia: é o que torna o upload testável sem disco e sem nuvem — veja o dublê no teste. O detalhe de projeto que decide se a abstração presta é o vocabulário: a interface fala em chave, não em caminho de arquivo. Interface que expõe $diretorio ou realpath() já nasceu acoplada ao disco, e o backend S3 vira gambiarra na hora de implementá-la.
<?php
// ---------------------------------------- src/Storage/StorageInterface.php
declare(strict_types=1);
namespace App\Storage;
/**
* O vocabulário é "chave", não "caminho": no S3 não existe diretório, existe
* prefixo no nome do objeto. Uma interface que falasse em `$diretorio` já
* nasceria vazando o backend local para dentro do domínio.
*/
interface StorageInterface
{
/** Move um upload temporário para o storage. Devolve a chave gravada. */
public function guardar(string $origemTemporaria, string $chave): string;
public function ler(string $chave): string;
public function existe(string $chave): bool;
public function remover(string $chave): bool;
/** URL pública (ou assinada) para exibir o objeto. */
public function url(string $chave): string;
}
// ---------------------------------------- src/Storage/FalhaDeStorage.php
declare(strict_types=1);
namespace App\Storage;
final class FalhaDeStorage extends \RuntimeException {}
// ---------------------------------------- src/Storage/LocalStorage.php
declare(strict_types=1);
namespace App\Storage;
final class LocalStorage implements StorageInterface
{
public function __construct(
private readonly string $raiz,
private readonly string $urlBase = '/arquivo',
) {
if (!is_dir($this->raiz) && !mkdir($this->raiz, 0755, true)) {
throw new FalhaDeStorage("Não consegui criar {$this->raiz}");
}
}
public function guardar(string $origemTemporaria, string $chave): string
{
$destino = $this->caminho($chave);
// A chave pode ter prefixo ("avatares/2026/abc.jpg"): o diretório
// correspondente precisa existir antes do move.
$pasta = dirname($destino);
if (!is_dir($pasta) && !mkdir($pasta, 0755, true)) {
throw new FalhaDeStorage("Não consegui criar {$pasta}");
}
// is_uploaded_file distingue upload de arquivo comum: o serviço
// também é usado em migração de dados, onde a origem não é HTTP.
$ok = is_uploaded_file($origemTemporaria)
? move_uploaded_file($origemTemporaria, $destino)
: rename($origemTemporaria, $destino);
if (!$ok) {
throw new FalhaDeStorage("Falha ao gravar a chave {$chave}");
}
chmod($destino, 0644);
return $chave;
}
public function ler(string $chave): string
{
$conteudo = @file_get_contents($this->caminho($chave));
if ($conteudo === false) {
throw new FalhaDeStorage("Chave inexistente: {$chave}");
}
return $conteudo;
}
public function existe(string $chave): bool
{
return is_file($this->caminho($chave));
}
public function remover(string $chave): bool
{
return $this->existe($chave) && unlink($this->caminho($chave));
}
public function url(string $chave): string
{
return $this->urlBase . '/' . $chave;
}
private function caminho(string $chave): string
{
// A chave vem do domínio, mas o custo de conferir é uma regex:
// um "../../.env" aqui daria leitura arbitrária de arquivo.
if (!preg_match('#^[A-Za-z0-9._/-]+$#', $chave) || str_contains($chave, '..')) {
throw new FalhaDeStorage("Chave inválida: {$chave}");
}
return $this->raiz . '/' . $chave;
}
}
// ---------------------------------------- src/Storage/S3Storage.php
declare(strict_types=1);
namespace App\Storage;
/**
* Simulação do S3 para o exercício: guarda os objetos numa pasta que faz as
* vezes de bucket e registra cada operação num log, como faria o SDK.
* Trocar por aws/aws-sdk-php é substituir o corpo dos cinco métodos — a
* assinatura, que é o que o resto do sistema conhece, não muda.
*/
final class S3Storage implements StorageInterface
{
public function __construct(
private readonly string $bucket,
private readonly string $regiao,
private readonly string $pastaSimulada,
private readonly string $arquivoDeLog,
) {}
public function guardar(string $origemTemporaria, string $chave): string
{
$destino = $this->pastaSimulada . '/' . str_replace('/', '__', $chave);
if (!copy($origemTemporaria, $destino)) {
throw new FalhaDeStorage("PutObject falhou para {$chave}");
}
@unlink($origemTemporaria);
$this->registrar('PutObject', $chave, filesize($destino) ?: 0);
return $chave;
}
public function ler(string $chave): string
{
$this->registrar('GetObject', $chave);
$conteudo = @file_get_contents($this->objeto($chave));
if ($conteudo === false) {
throw new FalhaDeStorage("NoSuchKey: {$chave}");
}
return $conteudo;
}
public function existe(string $chave): bool
{
$this->registrar('HeadObject', $chave);
return is_file($this->objeto($chave));
}
public function remover(string $chave): bool
{
$this->registrar('DeleteObject', $chave);
// O S3 devolve 204 mesmo para chave inexistente — DeleteObject é
// idempotente. A simulação imita isso em vez de devolver false.
@unlink($this->objeto($chave));
return true;
}
public function url(string $chave): string
{
return sprintf('https://%s.s3.%s.amazonaws.com/%s',
$this->bucket, $this->regiao, rawurlencode($chave));
}
private function objeto(string $chave): string
{
return $this->pastaSimulada . '/' . str_replace('/', '__', $chave);
}
private function registrar(string $operacao, string $chave, int $bytes = 0): void
{
$linha = sprintf(
"%s %-13s s3://%s/%s%s\n",
(new \DateTimeImmutable())->format('Y-m-d H:i:s'),
$operacao,
$this->bucket,
$chave,
$bytes > 0 ? " ({$bytes} bytes)" : '',
);
file_put_contents($this->arquivoDeLog, $linha, FILE_APPEND | LOCK_EX);
}
}
// ---------------------------------------- src/Upload/ServicoDeUpload.php
declare(strict_types=1);
namespace App\Upload;
use App\Storage\StorageInterface;
use finfo;
final class ServicoDeUpload
{
/**
* O backend chega pronto pelo construtor. O serviço não sabe — e não pode
* saber — se está falando com disco, S3 ou um dublê de teste.
*/
public function __construct(
private readonly StorageInterface $storage,
private readonly int $tamanhoMaximo = 2 * 1024 * 1024,
/** @var array<string, string> MIME real → extensão */
private readonly array $tiposAceitos = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
],
) {}
public function receber(array $arquivo, string $prefixo = 'avatares'): string
{
if ($arquivo['error'] !== UPLOAD_ERR_OK) {
throw FalhaNoUpload::doCodigoPhp($arquivo['error']);
}
if ($arquivo['size'] > $this->tamanhoMaximo) {
throw new FalhaNoUpload('Arquivo grande demais.');
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($arquivo['tmp_name']);
if (!is_string($mime) || !isset($this->tiposAceitos[$mime])) {
throw new FalhaNoUpload('Formato não aceito.');
}
// Prefixo por data espalha os objetos: 200 mil arquivos numa pasta só
// deixam qualquer `ls` — e o próprio filesystem — de joelhos.
$chave = sprintf(
'%s/%s/%s.%s',
$prefixo,
date('Y/m'),
bin2hex(random_bytes(16)),
$this->tiposAceitos[$mime],
);
return $this->storage->guardar($arquivo['tmp_name'], $chave);
}
public function url(string $chave): string
{
return $this->storage->url($chave);
}
}
// ---------------------------------------- config/storage.php (composition root)
declare(strict_types=1);
use App\Storage\LocalStorage;
use App\Storage\S3Storage;
use App\Storage\StorageInterface;
// O ÚNICO ponto do sistema que decide qual backend existe.
return static function (): StorageInterface {
return match (getenv('STORAGE_DRIVER') ?: 'local') {
's3' => new S3Storage(
bucket: getenv('S3_BUCKET') ?: 'meu-bucket',
regiao: getenv('S3_REGIAO') ?: 'us-east-1',
pastaSimulada: __DIR__ . '/../storage/s3-simulado',
arquivoDeLog: __DIR__ . '/../storage/logs/s3.log',
),
default => new LocalStorage(__DIR__ . '/../storage/publico'),
};
};
// ---------------------------------------- tests/ServicoDeUploadTest.php
declare(strict_types=1);
namespace Tests;
use App\Storage\StorageInterface;
use App\Upload\ServicoDeUpload;
use PHPUnit\Framework\TestCase;
final class ServicoDeUploadTest extends TestCase
{
public function testGeraChaveComPrefixoDeDataEEstensaoDoMimeReal(): void
{
// O dublê é a prova de que a injeção valeu a pena: teste de upload
// sem disco, sem rede e sem credencial de nuvem.
$storage = new class implements StorageInterface {
public array $guardados = [];
public function guardar(string $origemTemporaria, string $chave): string
{
$this->guardados[] = $chave;
return $chave;
}
public function ler(string $chave): string { return ''; }
public function existe(string $chave): bool { return in_array($chave, $this->guardados, true); }
public function remover(string $chave): bool { return true; }
public function url(string $chave): string { return '/memoria/' . $chave; }
};
// Um PNG 1x1 de verdade: o serviço lê o MIME dos bytes, então não
// adianta um arquivo de texto com nome .png.
$temporario = tempnam(sys_get_temp_dir(), 'up');
file_put_contents($temporario, base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk'
. 'YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
));
$chave = (new ServicoDeUpload($storage))->receber([
'name' => 'foto.png',
'type' => 'image/png',
'tmp_name' => $temporario,
'error' => UPLOAD_ERR_OK,
'size' => filesize($temporario),
]);
$this->assertMatchesRegularExpression(
'#^avatares/\d{4}/\d{2}/[0-9a-f]{32}\.png$#',
$chave,
);
$this->assertSame([$chave], $storage->guardados);
unlink($temporario);
}
}
Exercício 5
Todo upload grava em dois lugares: o disco, que não tem transação, e o banco, que tem. Escreva um serviço que lide com a falha de cada um dos dois — e decida, justificando, em que ordem gravar. Depois implemente a varredura que recolhe o que sobrou quando nem o tratamento de erro chegou a rodar, tomando o cuidado de não apagar arquivo de upload que ainda esteja em andamento.
Ver resposta
✓ Resposta: Não existe forma de fazer disco e banco acertarem juntos; o que se escolhe é qual inconsistência sobra. Gravando o arquivo primeiro, uma falha no banco deixa um arquivo sem dono: lixo invisível, que uma rotina recolhe depois e que não quebra nada enquanto existe. Na ordem inversa, a falha deixa um registro apontando para arquivo inexistente — a galeria abre com imagem quebrada e o download devolve 404, ou seja, o defeito chega ao usuário. Entre lixo e registro mentiroso, prefira sempre o lixo. O unlink() no catch cobre o caso normal, mas não o processo morto no meio do caminho, e é por isso que a varredura existe. Nela há um detalhe que decide tudo: filtrar por idade. Sem o corte de uma hora, a limpeza apaga exatamente os arquivos que estão sendo enviados neste instante, ainda sem registro no banco.
<?php
declare(strict_types=1);
/**
* Upload envolve dois sistemas: o disco, que não tem transação, e o banco,
* que tem. Fazer os dois "darem certo juntos" é impossível — o que dá para
* escolher é QUAL inconsistência sobra quando algo falha no meio.
*/
final class UploadTransacional
{
public function __construct(
private readonly PDO $pdo,
private readonly string $diretorio,
) {}
/**
* Ordem certa: grava o arquivo PRIMEIRO, depois o registro no banco.
*
* Se o banco falhar, sobra um arquivo sem dono — lixo, que uma rotina
* de limpeza recolhe depois, e que não quebra nada enquanto existe.
*
* Na ordem inversa, a falha deixa um registro apontando para um arquivo
* que não existe: a galeria abre com imagem quebrada, o download devolve
* 404 e o defeito aparece para o usuário. Entre lixo invisível e
* registro mentiroso, o lixo é sempre o lado melhor de errar.
*/
public function salvar(string $conteudo, string $nomeOriginal, int $usuarioId): int
{
$chave = bin2hex(random_bytes(16));
$destino = $this->diretorio . '/' . $chave;
if (file_put_contents($destino, $conteudo) === false) {
throw new RuntimeException('Falha ao gravar o arquivo.');
}
try {
$this->pdo->beginTransaction();
$stmt = $this->pdo->prepare(
'INSERT INTO arquivos (chave, nome_original, usuario_id, tamanho)
VALUES (?, ?, ?, ?)'
);
$stmt->execute([$chave, $nomeOriginal, $usuarioId, strlen($conteudo)]);
$id = (int) $this->pdo->lastInsertId();
$this->pdo->commit();
return $id;
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
// Desfaz o que não tem rollback automático.
@unlink($destino);
throw $e;
}
}
/**
* A rede de segurança: nem sempre o unlink() acima chega a rodar — o
* processo pode morrer entre a gravação e o commit. Esta varredura
* recolhe o que ficou, e só mexe no que é velho o bastante para não
* estar no meio de um upload em andamento.
*/
public function limparOrfaos(int $idadeMinimaSegundos = 3600): array
{
$registrados = $this->pdo->query('SELECT chave FROM arquivos')
->fetchAll(PDO::FETCH_COLUMN);
$registrados = array_flip($registrados);
$removidos = [];
foreach (glob($this->diretorio . '/*') ?: [] as $caminho) {
$chave = basename($caminho);
if (isset($registrados[$chave])) continue;
if (time() - filemtime($caminho) < $idadeMinimaSegundos) continue;
@unlink($caminho);
$removidos[] = $chave;
}
return $removidos;
}
}
// ── Demonstração ──────────────────────────────────────────────────────
$dir = sys_get_temp_dir() . '/up_' . bin2hex(random_bytes(4));
mkdir($dir, 0755, true);
$pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('CREATE TABLE arquivos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chave TEXT NOT NULL UNIQUE,
nome_original TEXT NOT NULL,
usuario_id INTEGER NOT NULL,
tamanho INTEGER NOT NULL
)');
$servico = new UploadTransacional($pdo, $dir);
$id = $servico->salvar('conteudo-ok', 'foto.jpg', 42);
echo " upload bem-sucedido -> id {$id}, arquivos em disco: " . count(glob("$dir/*")) . "\n";
// Falha no banco depois do arquivo já estar em disco: o catch precisa
// desfazer à mão o que não tem rollback.
try {
$servico->salvar('conteudo', 'igual.jpg', 42);
$pdo->exec('DROP TABLE arquivos'); // quebra o INSERT seguinte
$servico->salvar('conteudo', 'outra.jpg', 42);
} catch (Throwable $e) {
echo " o INSERT falhou -> o arquivo recém-gravado foi apagado no catch\n";
echo " arquivos em disco agora: " . count(glob("$dir/*")) . "\n";
}
$pdo->exec('CREATE TABLE arquivos (id INTEGER PRIMARY KEY AUTOINCREMENT, chave TEXT NOT NULL UNIQUE, nome_original TEXT NOT NULL, usuario_id INTEGER NOT NULL, tamanho INTEGER NOT NULL)');
// Deixa um órfão antigo e roda a limpeza
file_put_contents("$dir/orfao_antigo", 'lixo');
touch("$dir/orfao_antigo", time() - 7200);
file_put_contents("$dir/orfao_recente", 'em andamento');
$removidos = $servico->limparOrfaos();
echo " órfãos removidos: " . count($removidos) . " (o recente foi preservado)\n";
echo " restam em disco: " . count(glob("$dir/*")) . "\n";
array_map('unlink', glob("$dir/*")); rmdir($dir);