Tema
Subida segura de imágenes
Función reutilizable que recibe un archivo de $_FILES, comprueba que sea realmente una imagen, le pone un nombre aleatorio, la redimensiona (para que no subas fotos de 8 MB del celular) y la guarda como JPG o WebP optimizado.
Qué valida
- Errores de subida de PHP (tamaño excedido, subida parcial...).
- Tamaño máximo configurable.
- Tipo real del archivo con
finfo(no confía en la extensión ni en lo que diga el navegador). - Que GD pueda abrirla (descarta archivos corruptos o disfrazados).
- Re-codifica la imagen: elimina metadatos EXIF (ubicación GPS) y cualquier código incrustado.
La función
php
<?php
/**
* Sube y redimensiona una imagen.
*
* @param array $archivo Elemento de $_FILES (p. ej. $_FILES['foto'])
* @param string $destino Carpeta donde guardar (debe existir y tener permisos de escritura)
* @param int $maxAncho Ancho máximo en píxeles
* @param int $maxAlto Alto máximo en píxeles
* @param int $maxMB Tamaño máximo permitido en MB
* @return string Nombre del archivo guardado (p. ej. "a3f9c1....webp")
* @throws RuntimeException con un mensaje apto para mostrar al usuario
*/
function subir_imagen(array $archivo, string $destino, int $maxAncho = 1600, int $maxAlto = 1600, int $maxMB = 5): string
{
// 1) Errores de PHP
if (!isset($archivo['error']) || is_array($archivo['error'])) {
throw new RuntimeException('Petición no válida.');
}
switch ($archivo['error']) {
case UPLOAD_ERR_OK: break;
case UPLOAD_ERR_NO_FILE: throw new RuntimeException('No seleccionaste ninguna imagen.');
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE: throw new RuntimeException("La imagen supera el tamaño permitido.");
default: throw new RuntimeException('Error al subir el archivo.');
}
// 2) Tamaño
if ($archivo['size'] > $maxMB * 1024 * 1024) {
throw new RuntimeException("La imagen no debe pesar más de {$maxMB} MB.");
}
// 3) Tipo MIME real
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($archivo['tmp_name']);
$permitidos = [
'image/jpeg' => 'imagecreatefromjpeg',
'image/png' => 'imagecreatefrompng',
'image/webp' => 'imagecreatefromwebp',
'image/gif' => 'imagecreatefromgif',
];
if (!isset($permitidos[$mime])) {
throw new RuntimeException('Formato no permitido. Usa JPG, PNG, WebP o GIF.');
}
// 4) Abrir con GD
[$ancho, $alto] = getimagesize($archivo['tmp_name']) ?: [0, 0];
if ($ancho === 0 || $ancho * $alto > 40_000_000) { // evita "bombas" de 40 megapíxeles
throw new RuntimeException('La imagen no es válida o es demasiado grande.');
}
$origen = @$permitidos[$mime]($archivo['tmp_name']);
if (!$origen) {
throw new RuntimeException('No se pudo procesar la imagen.');
}
// Corrige la orientación de fotos de celular (EXIF)
if ($mime === 'image/jpeg' && function_exists('exif_read_data')) {
$exif = @exif_read_data($archivo['tmp_name']);
$giro = ['3' => 180, '6' => -90, '8' => 90][$exif['Orientation'] ?? ''] ?? 0;
if ($giro) {
$origen = imagerotate($origen, $giro, 0);
[$ancho, $alto] = [imagesx($origen), imagesy($origen)];
}
}
// 5) Calcular nuevo tamaño manteniendo proporción (nunca agranda)
$escala = min($maxAncho / $ancho, $maxAlto / $alto, 1);
$nuevoAncho = (int) round($ancho * $escala);
$nuevoAlto = (int) round($alto * $escala);
$lienzo = imagecreatetruecolor($nuevoAncho, $nuevoAlto);
// Conserva transparencia (PNG/WebP/GIF)
imagealphablending($lienzo, false);
imagesavealpha($lienzo, true);
imagecopyresampled($lienzo, $origen, 0, 0, 0, 0, $nuevoAncho, $nuevoAlto, $ancho, $alto);
// 6) Guardar con nombre aleatorio
if (!is_dir($destino) && !mkdir($destino, 0755, true)) {
throw new RuntimeException('No se pudo crear la carpeta de destino.');
}
$usarWebp = function_exists('imagewebp');
$nombre = bin2hex(random_bytes(16)) . ($usarWebp ? '.webp' : '.jpg');
$ruta = rtrim($destino, '/') . '/' . $nombre;
$ok = $usarWebp ? imagewebp($lienzo, $ruta, 82) : imagejpeg($lienzo, $ruta, 85);
imagedestroy($origen);
imagedestroy($lienzo);
if (!$ok) {
throw new RuntimeException('No se pudo guardar la imagen.');
}
return $nombre;
}
/** Genera una miniatura cuadrada recortada al centro (para tarjetas o avatares) */
function crear_miniatura(string $rutaOrigen, string $rutaDestino, int $lado = 400): void
{
$info = getimagesize($rutaOrigen);
$img = match ($info['mime']) {
'image/webp' => imagecreatefromwebp($rutaOrigen),
'image/png' => imagecreatefrompng($rutaOrigen),
default => imagecreatefromjpeg($rutaOrigen),
};
$w = imagesx($img); $h = imagesy($img);
$corte = min($w, $h);
$thumb = imagecreatetruecolor($lado, $lado);
imagecopyresampled($thumb, $img, 0, 0, (int)(($w - $corte) / 2), (int)(($h - $corte) / 2), $lado, $lado, $corte, $corte);
imagewebp($thumb, $rutaDestino, 80);
imagedestroy($img); imagedestroy($thumb);
}Ejemplo de uso: foto de perfil
php
<?php
require __DIR__ . '/includes/auth.php';
require __DIR__ . '/includes/subir_imagen.php';
$u = requiere_login();
$mensaje = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
try {
$nombre = subir_imagen($_FILES['foto'] ?? [], __DIR__ . '/uploads/avatares', 600, 600, 3);
crear_miniatura(__DIR__ . "/uploads/avatares/$nombre", __DIR__ . "/uploads/avatares/thumb_$nombre", 150);
db()->prepare('UPDATE usuarios SET avatar = ? WHERE id = ?')->execute([$nombre, $u['id']]);
$mensaje = '¡Foto actualizada!';
} catch (RuntimeException $e) {
$mensaje = $e->getMessage();
}
}
?>
<form method="post" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="file" name="foto" accept="image/jpeg,image/png,image/webp" required>
<button>Subir</button>
</form>
<p><?= e($mensaje) ?></p>Varias imágenes a la vez (galería)
php
<?php
// HTML: <input type="file" name="fotos[]" multiple>
// $_FILES['fotos'] llega "al revés"; esta función lo normaliza
function normalizar_archivos(array $files): array
{
$lista = [];
foreach ($files['name'] as $i => $nombre) {
$lista[] = [
'name' => $nombre,
'type' => $files['type'][$i],
'tmp_name' => $files['tmp_name'][$i],
'error' => $files['error'][$i],
'size' => $files['size'][$i],
];
}
return $lista;
}
$guardadas = [];
foreach (normalizar_archivos($_FILES['fotos']) as $archivo) {
try {
$guardadas[] = subir_imagen($archivo, __DIR__ . '/uploads/galeria');
} catch (RuntimeException $e) {
$errores[] = $archivo['name'] . ': ' . $e->getMessage();
}
}Proteger la carpeta uploads
Aunque sólo guardamos imágenes re-codificadas, añade esta capa extra: impide que Apache ejecute PHP dentro de uploads/.
apache
# uploads/.htaccess
php_flag engine off
<FilesMatch "\.(php|phtml|phar|php\d)$">
Require all denied
</FilesMatch>
Options -IndexesConfiguración de PHP
Si las fotos grandes fallan sin mensaje, revisa upload_max_filesize y post_max_size en php.ini (o en "Select PHP Version → Options" de cPanel). Por ejemplo: upload_max_filesize = 10M y post_max_size = 12M.
Error común
Guardar el archivo con su nombre original ($_FILES['foto']['name']). Puede contener rutas (../../index.php), caracteres raros o sobrescribir otra imagen. Genera siempre un nombre aleatorio y guarda el nombre original en la base de datos si lo necesitas.